diff --git a/packages/kbn-openapi-bundler/README.md b/packages/kbn-openapi-bundler/README.md index 4a82cb9c20339..0a096b3f28152 100644 --- a/packages/kbn-openapi-bundler/README.md +++ b/packages/kbn-openapi-bundler/README.md @@ -1,15 +1,15 @@ # OpenAPI Specs Bundler for Kibana -`@kbn/openapi-bundler` is a tool for transforming multiple OpenAPI specification files (source specs) into a single bundled specification file (target spec). -This can be used for API docs generation purposes. This approach allows you to: +`@kbn/openapi-bundler` is a tool for transforming multiple OpenAPI specification files (source specs) into a bundled specification file(s) (target spec). The number of resulting bundles depends on a number of versions +used in the OpenAPI specification files. The package can be used for API documentation generation purposes. This approach allows you to: -- Abstract away the knowledge of where you keep your OpenAPI specs, how many specs there are, and how to find them. The Docs team should only know where a single file is located - the bundle. +- Abstract away the knowledge of where you keep your OpenAPI specs, how many specs are there, and how to find them. Consumer should only know where result files are located - the bundles. - Omit internal API endpoints from the bundle. - Omit API endpoints that are hidden behind a feature flag and haven't been released yet. - Omit parts of schemas that are hidden behind a feature flag (e.g. a new property added to an existing response schema). - Omit custom OpenAPI attributes from the bundle, such as `x-codegen-enabled`, `x-internal`, and `x-modify` (see below). - Transform the target schema according to the custom OpenAPI attributes, such as `x-modify`. -- Resolve references and inline some of them for better readability. The bundled file contains only local references and paths. +- Resolve references, inline some of them and merge allOf object schemas for better readability. The bundled file contains only local references and paths. ## Getting started @@ -22,16 +22,22 @@ Currently package supports only programmatic API. As the next step you need to c ```ts require('../../../../../src/setup_node_env'); const { bundle } = require('@kbn/openapi-bundler'); -const { resolve } = require('path'); +const { join, resolve } = require('path'); // define ROOT as `my-plugin` instead of `my-plugin/scripts/openapi` // pay attention to this constant when your script's location is different const ROOT = resolve(__dirname, '../..'); bundle({ - rootDir: ROOT, // Root path e.g. plugin root directory - sourceGlob: './**/*.schema.yaml', // Glob pattern to find OpenAPI specification files - outputFilePath: './target/openapi/my-plugin.bundled.schema.yaml', // + // Root path e.g. plugin root directory + rootDir: ROOT, + // Glob pattern to find OpenAPI specification files, relative to `rootDir` + sourceGlob: './**/*.schema.yaml', + // Output file path. Absolute or related to the node.js working directory. + // It may contain `{version}` placeholder which is optional. `{version}` placeholder + // will be replaced with the bundled specs version or filename will be prepended with + // version when placeholder is omitted, e.g. `2023-10-31-my-plugin.bundled.schema.yaml`. + outputFilePath: join(ROOT, 'target/openapi/my-plugin-{version}.bundled.schema.yaml'), }); ``` diff --git a/packages/kbn-openapi-bundler/src/__test__/bundle_refs.test.ts b/packages/kbn-openapi-bundler/src/__test__/bundle_refs.test.ts new file mode 100644 index 0000000000000..3d5472d4d8308 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/bundle_refs.test.ts @@ -0,0 +1,407 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { OpenAPIV3 } from 'openapi-types'; +import { bundleSpecs } from './bundle_specs'; +import { createOASDocument } from './create_oas_document'; + +describe('OpenAPI Bundler - bundle references', () => { + it('bundles files with external references', async () => { + const spec1 = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: './common.schema.yaml#/components/schemas/TestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + }); + const spec2 = createOASDocument({ + paths: { + '/api/some_api': { + post: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: './common.schema.yaml#/components/schemas/TestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + }); + const commonSpec = createOASDocument({ + info: { + version: 'not set', + }, + components: { + schemas: { + TestSchema: { + type: 'string', + enum: ['value1', 'value2'], + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec1, + 2: spec2, + common: commonSpec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get!.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TestSchema', + }, + }, + }, + }); + expect(bundledSpec.paths['/api/some_api']!.post!.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TestSchema', + }, + }, + }, + }); + expect(bundledSpec.components!.schemas).toMatchObject({ + TestSchema: commonSpec.components!.schemas!.TestSchema, + }); + }); + + it('bundles one file with a local reference', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + TestSchema: { + type: 'string', + enum: ['value1', 'value2'], + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']).toEqual({ + get: expect.objectContaining({ + responses: expect.objectContaining({ + '200': expect.objectContaining({ + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TestSchema', + }, + }, + }, + }), + }), + }), + }); + expect(bundledSpec.components!.schemas!.TestSchema).toEqual( + spec.components!.schemas!.TestSchema + ); + }); + + it('bundles one file with an external reference', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: './common.schema.yaml#/components/schemas/TestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + }); + const commonSpec = createOASDocument({ + info: { + version: 'not set', + }, + components: { + schemas: { + TestSchema: { + type: 'string', + enum: ['value1', 'value2'], + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + common: commonSpec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']).toEqual({ + get: expect.objectContaining({ + responses: expect.objectContaining({ + '200': expect.objectContaining({ + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TestSchema', + }, + }, + }, + }), + }), + }), + }); + expect(bundledSpec.components!.schemas!.TestSchema).toMatchObject( + commonSpec.components!.schemas!.TestSchema + ); + }); + + it('bundles conflicting but equal references', async () => { + const ConflictTestSchema: OpenAPIV3.SchemaObject = { + type: 'integer', + minimum: 1, + }; + const spec1 = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ConflictTestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { ConflictTestSchema }, + }, + }); + const spec2 = createOASDocument({ + paths: { + '/api/some_api': { + put: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ConflictTestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { ConflictTestSchema }, + }, + }); + + const { '2023-10-31.yaml': bundledSpec } = await bundleSpecs({ '1': spec1, '2': spec2 }); + + expect(bundledSpec.paths['/api/some_api']).toEqual({ + get: spec1.paths['/api/some_api']!.get, + put: spec2.paths['/api/some_api']!.put, + }); + expect(bundledSpec.components).toMatchObject({ schemas: { ConflictTestSchema } }); + }); + + it('DOES NOT bundle external conflicting references encountered in on spec file', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + fieldA: { + $ref: './common_a.schema.yaml#/components/schemas/ConflictTestSchema', + }, + fieldB: { + $ref: './common_b.schema.yaml#/components/schemas/ConflictTestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + const commonSpecA = createOASDocument({ + components: { + schemas: { + ConflictTestSchema: { + type: 'string', + enum: ['value1', 'value2'], + }, + }, + }, + }); + const commonSpecB = createOASDocument({ + components: { + schemas: { + ConflictTestSchema: { + type: 'object', + properties: { + someField: { + type: 'string', + }, + }, + }, + }, + }, + }); + + await expect( + bundleSpecs({ + 1: spec, + common_a: commonSpecA, + common_b: commonSpecB, + }) + ).rejects.toThrowError(/\/components\/schemas\/ConflictTestSchema/); + }); + + it('DOES NOT bundle conflicting references encountered in separate specs', async () => { + const spec1 = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ConflictTestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + ConflictTestSchema: { + type: 'string', + enum: ['value1', 'value2'], + }, + }, + }, + }); + const spec2 = createOASDocument({ + paths: { + '/api/some_api': { + put: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ConflictTestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + ConflictTestSchema: { + type: 'integer', + minimum: 1, + }, + }, + }, + }); + + await expect( + bundleSpecs({ + 1: spec1, + 2: spec2, + }) + ).rejects.toThrowError(/\/components\/schemas\/ConflictTestSchema/); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/__test__/bundle_simple_specs.test.ts b/packages/kbn-openapi-bundler/src/__test__/bundle_simple_specs.test.ts new file mode 100644 index 0000000000000..15ef0e8215908 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/bundle_simple_specs.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { bundleSpecs } from './bundle_specs'; +import { createOASDocument } from './create_oas_document'; + +describe('OpenAPI Bundler - simple specs', () => { + it('bundles two simple specs', async () => { + const spec1 = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + fieldA: { + type: 'integer', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + const spec2 = createOASDocument({ + paths: { + '/api/some_api': { + post: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + fieldB: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec1, + 2: spec2, + }) + ); + + expect(bundledSpec.paths['/api/some_api']).toEqual({ + get: spec1.paths['/api/some_api']?.get, + post: spec2.paths['/api/some_api']?.post, + }); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/__test__/bundle_specs.ts b/packages/kbn-openapi-bundler/src/__test__/bundle_specs.ts new file mode 100644 index 0000000000000..599f81c0e4c85 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/bundle_specs.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { join } from 'path'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import { dump, load } from 'js-yaml'; +import { OpenAPIV3 } from 'openapi-types'; +import { bundle } from '../openapi_bundler'; + +const ROOT_PATH = join(__dirname, '..', '..'); + +export async function bundleSpecs( + oasSpecs: Record +): Promise> { + const randomStr = (Math.random() + 1).toString(36).substring(7); + const folderToBundlePath = join(ROOT_PATH, 'target', 'oas-test', randomStr); + const resultFolderPath = join(ROOT_PATH, 'target', 'oas-test-bundled-result', randomStr); + const bundledFilePathTemplate = join(resultFolderPath, '{version}.yaml'); + + dumpSpecs(folderToBundlePath, oasSpecs); + + await bundleFolder(folderToBundlePath, bundledFilePathTemplate); + + return readBundledSpecs(resultFolderPath); +} + +function removeFolder(folderPath: string): void { + if (existsSync(folderPath)) { + for (const fileName of readdirSync(folderPath)) { + unlinkSync(join(folderPath, fileName)); + } + + rmdirSync(folderPath); + } +} + +function dumpSpecs(folderPath: string, oasSpecs: Record): void { + removeFolder(folderPath); + mkdirSync(folderPath, { recursive: true }); + + for (const [fileName, oasSpec] of Object.entries(oasSpecs)) { + writeFileSync(join(folderPath, `${fileName}.schema.yaml`), dump(oasSpec)); + } +} + +export function readBundledSpecs(folderPath: string): Record { + const bundledSpecs: Record = {}; + + for (const fileName of readdirSync(folderPath)) { + const yaml = readFileSync(join(folderPath, fileName), { encoding: 'utf8' }); + + bundledSpecs[fileName] = load(yaml); + } + + return bundledSpecs; +} + +export async function bundleFolder( + folderToBundlePath: string, + bundledFilePathTemplate: string +): Promise { + await bundle({ + sourceGlob: join(folderToBundlePath, '*.schema.yaml'), + outputFilePath: bundledFilePathTemplate, + }); +} diff --git a/packages/kbn-openapi-bundler/src/__test__/bundle_specs_with_multiple_modifications.test.ts b/packages/kbn-openapi-bundler/src/__test__/bundle_specs_with_multiple_modifications.test.ts new file mode 100644 index 0000000000000..ed57d745f2ee4 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/bundle_specs_with_multiple_modifications.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { readFileSync } from 'fs'; +import { load } from 'js-yaml'; +import { join } from 'path'; +import { bundleFolder, readBundledSpecs } from './bundle_specs'; + +const ROOT_PATH = join(__dirname, '..', '..'); + +describe('OpenAPI Bundler - specs with multiple modifications', () => { + it('bundles specs performing multiple modifications without interference', async () => { + const folderToBundlePath = join(__dirname, 'complex_specs'); + const outputFolderPath = join(ROOT_PATH, 'target', 'complex_specs_test'); + const bundledFilePathTemplate = join(outputFolderPath, 'oas-test-bundle-{version}.yaml'); + + await bundleFolder(folderToBundlePath, bundledFilePathTemplate); + + const [bundledSpec] = Object.values(readBundledSpecs(outputFolderPath)); + + const expected = load( + readFileSync(join(folderToBundlePath, 'expected.yaml'), { encoding: 'utf8' }) + ); + + expect(bundledSpec).toEqual(expected); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/__test__/circular.test.ts b/packages/kbn-openapi-bundler/src/__test__/circular.test.ts new file mode 100644 index 0000000000000..8d72f28c3779f --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/circular.test.ts @@ -0,0 +1,228 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { dump } from 'js-yaml'; +import { OpenAPIV3 } from 'openapi-types'; +import { bundleSpecs } from './bundle_specs'; +import { createOASDocument } from './create_oas_document'; + +describe('OpenAPI Bundler - circular specs', () => { + it('bundles recursive spec', async () => { + const recursiveSchema: OpenAPIV3.SchemaObject = { + type: 'object', + properties: { + fieldA: { + type: 'integer', + }, + }, + }; + recursiveSchema.properties!.fieldB = recursiveSchema; + + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: recursiveSchema, + }, + }, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(dump(bundledSpec.paths['/api/some_api']!.get!.responses['200'])).toMatchInlineSnapshot(` +"description: Successful response +content: + application/json: + schema: &ref_0 + type: object + properties: + fieldA: + type: integer + fieldB: *ref_0 +" +`); + }); + + it('bundles specs with recursive references', async () => { + const spec1 = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: './common.schema.yaml#/components/schemas/CircularTestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + }); + const spec2 = createOASDocument({ + paths: { + '/api/some_api': { + post: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: './common.schema.yaml#/components/schemas/CircularTestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + }); + const commonSpec = createOASDocument({ + info: { + version: 'not set', + }, + components: { + schemas: { + CircularTestSchema: { + type: 'object', + properties: { + field: { + $ref: '#/components/schemas/AnotherCircularTestSchema', + }, + }, + }, + AnotherCircularTestSchema: { + anyOf: [ + { $ref: '#/components/schemas/CircularTestSchema' }, + { type: 'string', enum: ['value1', 'value2'] }, + ], + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec1, + 2: spec2, + common: commonSpec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']).toEqual({ + get: expect.objectContaining({ + responses: expect.objectContaining({ + '200': expect.objectContaining({ + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/CircularTestSchema', + }, + }, + }, + }), + }), + }), + post: expect.objectContaining({ + responses: expect.objectContaining({ + '200': expect.objectContaining({ + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/CircularTestSchema', + }, + }, + }, + }), + }), + }), + }); + expect(bundledSpec.components).toMatchObject({ schemas: commonSpec.components!.schemas }); + }); + + it('bundles spec with a self-recursive reference', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/CircularTestSchema', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + CircularTestSchema: { + type: 'object', + properties: { + field: { + $ref: '#/components/schemas/CircularTestSchema', + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']).toEqual({ + get: expect.objectContaining({ + responses: expect.objectContaining({ + '200': expect.objectContaining({ + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/CircularTestSchema', + }, + }, + }, + }), + }), + }), + }); + expect(bundledSpec.components!.schemas!.CircularTestSchema).toEqual( + spec.components!.schemas!.CircularTestSchema + ); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/__test__/complex_specs/common.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/complex_specs/common.schema.yaml new file mode 100644 index 0000000000000..aaa7af54a2fd1 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/complex_specs/common.schema.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.3 +info: + title: Test endpoint + version: 'no applicable' +paths: {} + +components: + schemas: + FieldSchemaA: + type: object + properties: + fieldX: + type: string + fieldY: + type: integer + + SchemaA: + type: object + properties: + schemaAField1: + type: string + enum: + - value1 + - value2 + schemaAField2: + type: integer + required: + - schemaAField1 + - schemaAField2 + + SchemaB: + allOf: + - $ref: '#/components/schemas/SharedSchema' + - $ref: '#/components/schemas/SchemaA' + - type: object + properties: + schemaBField: + type: boolean + + SharedSchema: + x-inline: true + type: object + properties: + fieldA: + type: string + fieldRef: + $ref: '#/components/schemas/FieldSchemaA' + x-modify: required + fieldB: + type: boolean + + SharedSchemaWithAllOf: + x-inline: true + allOf: + - type: object + properties: + sharedSchemaFieldX: + type: string + sharedSchemaFieldY: + type: string + commonField: + type: string + required: + - commonField + - type: object + properties: + sharedSchemaField1: + type: string + sharedSchemaField2: + type: string diff --git a/packages/kbn-openapi-bundler/src/__test__/complex_specs/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/complex_specs/expected.yaml new file mode 100644 index 0000000000000..9a7660f14070e --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/complex_specs/expected.yaml @@ -0,0 +1,172 @@ +openapi: 3.0.3 +info: + title: Bundled OpenAPI specs + version: '2023-10-31' +servers: + - url: 'http://{kibana_host}:{port}' + variables: + kibana_host: + default: localhost + port: + default: '5601' +security: + - BasicAuth: [] + +paths: + /api/some_api: + get: + operationId: TestEndpointGet + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + fieldA: + type: string + fieldRef: + type: object + properties: + fieldX: + type: string + fieldY: + type: integer + required: + - fieldX + - fieldY + fieldB: + type: boolean + post: + operationId: TestEndpointPost + responses: + '200': + description: Successful response + content: + application/json: + schema: + anyOf: + - type: object + properties: + localField1: + type: string + localField2: + type: string + required: + - localField1 + - type: object + properties: + schemaAField1: + type: string + enum: + - value1 + - value2 + schemaAField2: + type: integer + - type: object + properties: + fieldA: + type: string + fieldRef: + type: object + properties: + fieldX: + type: string + fieldY: + type: integer + required: + - fieldX + - fieldY + fieldB: + type: boolean + put: + operationId: TestEndpointPut + responses: + '200': + description: Successful response + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/SchemaWithOptionalFields' + - $ref: '#/components/schemas/SchemaB' + +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic + schemas: + SchemaWithOptionalFields: + type: object + properties: + fieldA: + type: boolean + fieldB: + type: object + properties: + fieldA: + type: string + fieldRef: + type: object + properties: + fieldX: + type: string + fieldY: + type: integer + required: + - fieldX + - fieldY + fieldB: + type: boolean + sharedSchemaFieldX: + type: string + sharedSchemaFieldY: + type: string + commonField: + type: string + sharedSchemaField1: + type: string + sharedSchemaField2: + type: string + localSchemaFieldA: + type: number + localSchemaFieldB: + type: string + required: + - commonField + SchemaA: + type: object + properties: + schemaAField1: + type: string + enum: + - value1 + - value2 + schemaAField2: + type: integer + required: + - schemaAField1 + - schemaAField2 + SchemaB: + allOf: + - type: object + properties: + fieldA: + type: string + fieldRef: + type: object + properties: + fieldX: + type: string + fieldY: + type: integer + required: + - fieldX + - fieldY + fieldB: + type: boolean + schemaBField: + type: boolean + - $ref: '#/components/schemas/SchemaA' diff --git a/packages/kbn-openapi-bundler/src/__test__/spec_with_external_ref/spec.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/complex_specs/spec1.schema.yaml similarity index 87% rename from packages/kbn-openapi-bundler/src/__test__/spec_with_external_ref/spec.schema.yaml rename to packages/kbn-openapi-bundler/src/__test__/complex_specs/spec1.schema.yaml index b1d910fa5e963..0d6656fde5b68 100644 --- a/packages/kbn-openapi-bundler/src/__test__/spec_with_external_ref/spec.schema.yaml +++ b/packages/kbn-openapi-bundler/src/__test__/complex_specs/spec1.schema.yaml @@ -12,4 +12,5 @@ paths: content: application/json: schema: - $ref: './common.schema.yaml#/components/schemas/TestSchema' + $ref: './common.schema.yaml#/components/schemas/SharedSchema' + x-modify: partial diff --git a/packages/kbn-openapi-bundler/src/__test__/complex_specs/spec2.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/complex_specs/spec2.schema.yaml new file mode 100644 index 0000000000000..a79a78f94950d --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/complex_specs/spec2.schema.yaml @@ -0,0 +1,26 @@ +openapi: 3.0.3 +info: + title: Test endpoint + version: '2023-10-31' +paths: + /api/some_api: + post: + operationId: TestEndpointPost + responses: + '200': + description: Successful response + content: + application/json: + schema: + anyOf: + - type: object + properties: + localField1: + type: string + localField2: + type: string + required: + - localField1 + - $ref: './common.schema.yaml#/components/schemas/SchemaA' + x-modify: partial + - $ref: './common.schema.yaml#/components/schemas/SharedSchema' diff --git a/packages/kbn-openapi-bundler/src/__test__/complex_specs/spec3.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/complex_specs/spec3.schema.yaml new file mode 100644 index 0000000000000..d249210fff2d2 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/complex_specs/spec3.schema.yaml @@ -0,0 +1,45 @@ +openapi: 3.0.3 +info: + title: Test endpoint + version: '2023-10-31' +paths: + /api/some_api: + put: + x-codegen-enabled: true + operationId: TestEndpointPut + responses: + '200': + description: Successful response + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/SchemaWithOptionalFields' + - $ref: './common.schema.yaml#/components/schemas/SchemaB' + +components: + schemas: + SchemaWithOptionalFields: + allOf: + - type: object + properties: + fieldA: + type: boolean + fieldB: + $ref: './common.schema.yaml#/components/schemas/SharedSchema' + - $ref: './common.schema.yaml#/components/schemas/SharedSchemaWithAllOf' + - $ref: '#/components/schemas/LocalSchemaWithAllOf' + x-inline: true + + LocalSchemaWithAllOf: + allOf: + - type: object + properties: + localSchemaFieldA: + type: number + localSchemaFieldB: + type: string + commonField: + type: string + required: + - commonField diff --git a/packages/kbn-openapi-bundler/src/__test__/conflicting_but_equal_refs_in_different_specs/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/conflicting_but_equal_refs_in_different_specs/expected.yaml deleted file mode 100644 index d8eb6a8b66c68..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/conflicting_but_equal_refs_in_different_specs/expected.yaml +++ /dev/null @@ -1,40 +0,0 @@ -spec1.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './shared_components.schema.yaml#/components/schemas/ConflictTestSchema' - -spec2.schema.yaml: - openapi: 3.0.3 - info: - title: Another test endpoint - version: '2023-10-31' - paths: - /api/another_api: - put: - operationId: AnotherTestEndpointPut - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './shared_components.schema.yaml#/components/schemas/ConflictTestSchema' - -shared_components.schema.yaml: - components: - schemas: - ConflictTestSchema: - type: integer - minimum: 1 diff --git a/packages/kbn-openapi-bundler/src/__test__/conflicting_but_equal_refs_in_different_specs/spec1.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/conflicting_but_equal_refs_in_different_specs/spec1.schema.yaml deleted file mode 100644 index a44cd371ba326..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/conflicting_but_equal_refs_in_different_specs/spec1.schema.yaml +++ /dev/null @@ -1,21 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/ConflictTestSchema' - -components: - schemas: - ConflictTestSchema: - type: integer - minimum: 1 diff --git a/packages/kbn-openapi-bundler/src/__test__/conflicting_but_equal_refs_in_different_specs/spec2.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/conflicting_but_equal_refs_in_different_specs/spec2.schema.yaml deleted file mode 100644 index 4a5670f8ae5f5..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/conflicting_but_equal_refs_in_different_specs/spec2.schema.yaml +++ /dev/null @@ -1,21 +0,0 @@ -openapi: 3.0.3 -info: - title: Another test endpoint - version: '2023-10-31' -paths: - /api/another_api: - put: - operationId: AnotherTestEndpointPut - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/ConflictTestSchema' - -components: - schemas: - ConflictTestSchema: - type: integer - minimum: 1 diff --git a/packages/kbn-openapi-bundler/src/__test__/conflicting_refs_in_different_specs/spec1.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/conflicting_refs_in_different_specs/spec1.schema.yaml deleted file mode 100644 index 765811b78a619..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/conflicting_refs_in_different_specs/spec1.schema.yaml +++ /dev/null @@ -1,23 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/ConflictTestSchema' - -components: - schemas: - ConflictTestSchema: - type: string - enum: - - value1 - - value2 diff --git a/packages/kbn-openapi-bundler/src/__test__/conflicting_refs_in_different_specs/spec2.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/conflicting_refs_in_different_specs/spec2.schema.yaml deleted file mode 100644 index 4a5670f8ae5f5..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/conflicting_refs_in_different_specs/spec2.schema.yaml +++ /dev/null @@ -1,21 +0,0 @@ -openapi: 3.0.3 -info: - title: Another test endpoint - version: '2023-10-31' -paths: - /api/another_api: - put: - operationId: AnotherTestEndpointPut - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/ConflictTestSchema' - -components: - schemas: - ConflictTestSchema: - type: integer - minimum: 1 diff --git a/packages/kbn-openapi-bundler/src/__test__/create_oas_document.ts b/packages/kbn-openapi-bundler/src/__test__/create_oas_document.ts new file mode 100644 index 0000000000000..c91e535e0cf12 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/create_oas_document.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { OpenAPIV3 } from 'openapi-types'; + +export function createOASDocument(overrides: { + openapi?: string; + info?: Partial; + paths?: OpenAPIV3.PathsObject; + components?: OpenAPIV3.ComponentsObject; +}): OpenAPIV3.Document { + return { + openapi: overrides.openapi ?? '3.0.3', + info: { + title: 'Test endpoint', + version: '2023-10-31', + ...overrides.info, + }, + paths: { + ...overrides.paths, + }, + components: { + ...overrides.components, + }, + }; +} diff --git a/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions.test.ts b/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions.test.ts new file mode 100644 index 0000000000000..5def26293084b --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { bundleSpecs } from './bundle_specs'; +import { createOASDocument } from './create_oas_document'; + +describe('OpenAPI Bundler - different API versions', () => { + it('bundles one endpoint with different versions', async () => { + const spec1 = createOASDocument({ + info: { + version: '2023-10-31', + }, + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + field1: { + type: 'integer', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + const spec2 = createOASDocument({ + info: { + version: '2023-11-11', + }, + paths: { + '/api/some_api': { + put: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + field1: { + type: 'integer', + }, + field2: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const bundledSpecs = await bundleSpecs({ + 1: spec1, + 2: spec2, + }); + + expect(bundledSpecs).toEqual({ + '2023-10-31.yaml': expect.objectContaining({ + paths: spec1.paths, + }), + '2023-11-11.yaml': expect.objectContaining({ + paths: spec2.paths, + }), + }); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions/expected.yaml deleted file mode 100644 index 0b916b7b17ac2..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions/expected.yaml +++ /dev/null @@ -1,44 +0,0 @@ -version1.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: integer - -version2.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-11-11' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: integer - field2: - type: string - -shared_components.schema.yaml: - components: {} diff --git a/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions/version1.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions/version1.schema.yaml deleted file mode 100644 index 5b7f6a8718fcf..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions/version1.schema.yaml +++ /dev/null @@ -1,18 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: integer diff --git a/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions/version2.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions/version2.schema.yaml deleted file mode 100644 index 4492f449ba2fe..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/different_endpoint_versions/version2.schema.yaml +++ /dev/null @@ -1,20 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-11-11' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: integer - field2: - type: string diff --git a/packages/kbn-openapi-bundler/src/__test__/different_oas_versions.test.ts b/packages/kbn-openapi-bundler/src/__test__/different_oas_versions.test.ts new file mode 100644 index 0000000000000..949f4f882ff4e --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/different_oas_versions.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { bundleSpecs } from './bundle_specs'; +import { createOASDocument } from './create_oas_document'; + +describe('OpenAPI Bundler - different OAS versions', () => { + it('DOES NOT bundle specs with different OpenAPI versions', async () => { + const spec1 = createOASDocument({ + openapi: '3.0.3', + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }); + const spec2 = createOASDocument({ + openapi: '3.1.0', + paths: { + '/api/some_api': { + put: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'boolean', + }, + }, + }, + }, + }, + }, + }, + }, + }); + + await expect( + bundleSpecs({ + 1: spec1, + 2: spec2, + }) + ).rejects.toThrowError(new RegExp('^OpenAPI specs must use the same OpenAPI version')); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/__test__/different_openapi_versions/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/different_openapi_versions/expected.yaml deleted file mode 100644 index 3aa6c7051ebcf..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/different_openapi_versions/expected.yaml +++ /dev/null @@ -1,42 +0,0 @@ -spec1.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: integer - -spec2.schema.yaml: - openapi: 3.1.0 - info: - title: Test endpoint POST - version: '2023-10-31' - paths: - /api/some_api: - post: - operationId: TestEndpointPost - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field2: - type: string - -shared_components.schema.yaml: - components: {} diff --git a/packages/kbn-openapi-bundler/src/__test__/different_openapi_versions/spec1.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/different_openapi_versions/spec1.schema.yaml deleted file mode 100644 index 5b7f6a8718fcf..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/different_openapi_versions/spec1.schema.yaml +++ /dev/null @@ -1,18 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: integer diff --git a/packages/kbn-openapi-bundler/src/__test__/different_openapi_versions/spec2.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/different_openapi_versions/spec2.schema.yaml deleted file mode 100644 index e437e40e6698e..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/different_openapi_versions/spec2.schema.yaml +++ /dev/null @@ -1,18 +0,0 @@ -openapi: 3.1.0 -info: - title: Test endpoint POST - version: '2023-10-31' -paths: - /api/some_api: - post: - operationId: TestEndpointPost - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field2: - type: string diff --git a/packages/kbn-openapi-bundler/src/__test__/inline_ref.test.ts b/packages/kbn-openapi-bundler/src/__test__/inline_ref.test.ts new file mode 100644 index 0000000000000..913afd934de09 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/inline_ref.test.ts @@ -0,0 +1,227 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { bundleSpecs } from './bundle_specs'; +import { createOASDocument } from './create_oas_document'; + +describe('OpenAPI Bundler - inline references', () => { + it('inlines local references', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + anyOf: [ + { $ref: '#/components/schemas/SchemaToInline' }, + { $ref: '#/components/schemas/SchemaNotToInline1' }, + { $ref: '#/components/schemas/SchemaNotToInline2' }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + SchemaToInline: { + // @ts-expect-error OpenAPIV3.Document doesn't allow to add custom props to components.schemas + 'x-inline': true, + type: 'object', + properties: { + field1: { + type: 'string', + enum: ['value1'], + }, + field2: { + type: 'integer', + minimum: 1, + }, + }, + }, + SchemaNotToInline1: { + // @ts-expect-error OpenAPIV3.Document doesn't allow to add custom props to components.schemas + 'x-inline': false, + type: 'object', + properties: { + field1: { + type: 'string', + enum: ['value1'], + }, + field2: { + type: 'integer', + minimum: 1, + }, + }, + }, + SchemaNotToInline2: { + type: 'object', + properties: { + field1: { + type: 'string', + enum: ['value1'], + }, + field2: { + type: 'integer', + minimum: 1, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get!.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { + anyOf: expect.arrayContaining([ + { + type: 'object', + properties: { + field1: { + type: 'string', + enum: ['value1'], + }, + field2: { + type: 'integer', + minimum: 1, + }, + }, + }, + ]), + }, + }, + }, + }); + expect(Object.keys(bundledSpec.components!.schemas!)).toEqual([ + 'SchemaNotToInline1', + 'SchemaNotToInline2', + ]); + }); + + it('inlines external references', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + anyOf: [ + { $ref: './common.schema.yaml#/components/schemas/SchemaToInline' }, + { $ref: './common.schema.yaml#/components/schemas/SchemaNotToInline1' }, + { $ref: './common.schema.yaml#/components/schemas/SchemaNotToInline2' }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }); + const commonSpec = createOASDocument({ + components: { + schemas: { + SchemaToInline: { + // @ts-expect-error OpenAPIV3.Document doesn't allow to add custom props to components.schemas + 'x-inline': true, + type: 'object', + properties: { + field1: { + type: 'string', + enum: ['value1'], + }, + field2: { + type: 'integer', + minimum: 1, + }, + }, + }, + SchemaNotToInline1: { + // @ts-expect-error OpenAPIV3.Document doesn't allow to add custom props to components.schemas + 'x-inline': false, + type: 'object', + properties: { + field1: { + type: 'string', + enum: ['value1'], + }, + field2: { + type: 'integer', + minimum: 1, + }, + }, + }, + SchemaNotToInline2: { + type: 'object', + properties: { + field1: { + type: 'string', + enum: ['value1'], + }, + field2: { + type: 'integer', + minimum: 1, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + common: commonSpec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get!.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { + anyOf: expect.arrayContaining([ + { + type: 'object', + properties: { + field1: { + type: 'string', + enum: ['value1'], + }, + field2: { + type: 'integer', + minimum: 1, + }, + }, + }, + ]), + }, + }, + }, + }); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/__test__/inline_ref/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/inline_ref/expected.yaml deleted file mode 100644 index 270886caa051e..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/inline_ref/expected.yaml +++ /dev/null @@ -1,50 +0,0 @@ -spec.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - anyOf: - - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - - $ref: './shared_components.schema.yaml#/components/schemas/TestSchema2' - - $ref: './shared_components.schema.yaml#/components/schemas/TestSchema3' - -shared_components.schema.yaml: - components: - schemas: - TestSchema2: - x-inline: false - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - - TestSchema3: - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 diff --git a/packages/kbn-openapi-bundler/src/__test__/inline_ref/spec.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/inline_ref/spec.schema.yaml deleted file mode 100644 index f5cdb2694a5c8..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/inline_ref/spec.schema.yaml +++ /dev/null @@ -1,52 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TestSchema1' - - $ref: '#/components/schemas/TestSchema2' - - $ref: '#/components/schemas/TestSchema3' - -components: - schemas: - TestSchema1: - x-inline: true - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - - TestSchema2: - x-inline: false - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - - TestSchema3: - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 diff --git a/packages/kbn-openapi-bundler/src/__test__/modify_partial_node/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/modify_partial_node/expected.yaml deleted file mode 100644 index fa679d0c3f8c0..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/modify_partial_node/expected.yaml +++ /dev/null @@ -1,26 +0,0 @@ -spec.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - -shared_components.schema.yaml: - components: {} diff --git a/packages/kbn-openapi-bundler/src/__test__/modify_partial_node/spec.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/modify_partial_node/spec.schema.yaml deleted file mode 100644 index 9646051aab907..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/modify_partial_node/spec.schema.yaml +++ /dev/null @@ -1,26 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - x-modify: partial - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - required: - - field1 - - field2 diff --git a/packages/kbn-openapi-bundler/src/__test__/modify_partial_ref/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/modify_partial_ref/expected.yaml deleted file mode 100644 index fa679d0c3f8c0..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/modify_partial_ref/expected.yaml +++ /dev/null @@ -1,26 +0,0 @@ -spec.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - -shared_components.schema.yaml: - components: {} diff --git a/packages/kbn-openapi-bundler/src/__test__/modify_partial_ref/spec.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/modify_partial_ref/spec.schema.yaml deleted file mode 100644 index 547bb4cd913be..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/modify_partial_ref/spec.schema.yaml +++ /dev/null @@ -1,31 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/TestSchema' - x-modify: partial - -components: - schemas: - TestSchema: - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - required: - - field1 - - field2 diff --git a/packages/kbn-openapi-bundler/src/__test__/modify_required_node/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/modify_required_node/expected.yaml deleted file mode 100644 index 3b075e3cf803b..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/modify_required_node/expected.yaml +++ /dev/null @@ -1,29 +0,0 @@ -spec.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - required: - - field1 - - field2 - -shared_components.schema.yaml: - components: {} diff --git a/packages/kbn-openapi-bundler/src/__test__/modify_required_node/spec.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/modify_required_node/spec.schema.yaml deleted file mode 100644 index 68d478ea8caaa..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/modify_required_node/spec.schema.yaml +++ /dev/null @@ -1,23 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - x-modify: required - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 diff --git a/packages/kbn-openapi-bundler/src/__test__/modify_required_ref/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/modify_required_ref/expected.yaml deleted file mode 100644 index 3b075e3cf803b..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/modify_required_ref/expected.yaml +++ /dev/null @@ -1,29 +0,0 @@ -spec.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - required: - - field1 - - field2 - -shared_components.schema.yaml: - components: {} diff --git a/packages/kbn-openapi-bundler/src/__test__/modify_required_ref/spec.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/modify_required_ref/spec.schema.yaml deleted file mode 100644 index 0f02e3e905e23..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/modify_required_ref/spec.schema.yaml +++ /dev/null @@ -1,28 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/TestSchema' - x-modify: required - -components: - schemas: - TestSchema: - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 diff --git a/packages/kbn-openapi-bundler/src/__test__/omit_unused_schemas.test.ts b/packages/kbn-openapi-bundler/src/__test__/omit_unused_schemas.test.ts new file mode 100644 index 0000000000000..b714ba5ddf834 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/omit_unused_schemas.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { bundleSpecs } from './bundle_specs'; +import { createOASDocument } from './create_oas_document'; + +describe('OpenAPI Bundler - omit unused schemas', () => { + it('omits unused local schema', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + TestSchema: { + type: 'string', + enum: ['value1', 'value2'], + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.components).not.toMatchObject({ schemas: expect.anything() }); + }); + + it('omits unused external schema', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: './common.schema.yaml#/components/schemas/SchemaA', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + TestSchema: { + type: 'string', + enum: ['value1', 'value2'], + }, + }, + }, + }); + const commonSpec = createOASDocument({ + components: { + schemas: { + SchemaA: { + type: 'number', + }, + SchemaB: { + type: 'string', + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + common: commonSpec, + }) + ); + + expect(bundledSpec.components!.schemas).toEqual({ SchemaA: expect.anything() }); + }); + + it('omits inlined schemas', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { $ref: './common.schema.yaml#/components/schemas/SchemaToInline' }, + }, + }, + }, + }, + }, + }, + }, + }); + const commonSpec = createOASDocument({ + components: { + schemas: { + SchemaToInline: { + // @ts-expect-error custom prop + 'x-inline': true, + type: 'string', + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + common: commonSpec, + }) + ); + + expect(bundledSpec.components).not.toMatchObject({ schemas: expect.anything() }); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/common.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/common.schema.yaml deleted file mode 100644 index 6e0ec47b773c5..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/common.schema.yaml +++ /dev/null @@ -1,21 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint - version: '2023-10-31' -paths: {} - -components: - schemas: - CircularTestSchema: - type: string - data: - items: - $ref: '#/components/schemas/AnotherCircularTestSchema' - - AnotherCircularTestSchema: - anyof: - - $ref: '#/components/schemas/CircularTestSchema' - - type: string - enum: - - value1 - - value2 diff --git a/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/expected.yaml deleted file mode 100644 index cce50159ca39f..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/expected.yaml +++ /dev/null @@ -1,50 +0,0 @@ -spec1.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './shared_components.schema.yaml#/components/schemas/CircularTestSchema' - -spec2.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint POST - version: '2023-10-31' - paths: - /api/some_api: - post: - operationId: TestEndpointPost - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './shared_components.schema.yaml#/components/schemas/CircularTestSchema' - -shared_components.schema.yaml: - components: - schemas: - CircularTestSchema: - type: string - data: - items: - $ref: '#/components/schemas/AnotherCircularTestSchema' - - AnotherCircularTestSchema: - anyof: - - $ref: '#/components/schemas/CircularTestSchema' - - type: string - enum: - - value1 - - value2 diff --git a/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/spec1.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/spec1.schema.yaml deleted file mode 100644 index 2e64f53087f84..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/spec1.schema.yaml +++ /dev/null @@ -1,15 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './common.schema.yaml#/components/schemas/CircularTestSchema' diff --git a/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/spec2.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/spec2.schema.yaml deleted file mode 100644 index 92ebc5f4468e5..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/recursive_ref_specs/spec2.schema.yaml +++ /dev/null @@ -1,15 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint POST - version: '2023-10-31' -paths: - /api/some_api: - post: - operationId: TestEndpointPost - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './common.schema.yaml#/components/schemas/CircularTestSchema' diff --git a/packages/kbn-openapi-bundler/src/__test__/recursive_spec/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/recursive_spec/expected.yaml deleted file mode 100644 index b5bb0cffb6390..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/recursive_spec/expected.yaml +++ /dev/null @@ -1,27 +0,0 @@ -spec.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: &ref0 - type: object - properties: - - name: field1 - required: false - schema: *ref0 - - field2: - required: false - schema: - type: string - -shared_components.schema.yaml: - components: {} diff --git a/packages/kbn-openapi-bundler/src/__test__/recursive_spec/spec.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/recursive_spec/spec.schema.yaml deleted file mode 100644 index f9e3d8b2c590e..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/recursive_spec/spec.schema.yaml +++ /dev/null @@ -1,23 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: &ref0 - type: object - properties: - - name: field1 - required: false - schema: *ref0 - - field2: - required: false - schema: - type: string diff --git a/packages/kbn-openapi-bundler/src/__test__/reduce_all_of.test.ts b/packages/kbn-openapi-bundler/src/__test__/reduce_all_of.test.ts new file mode 100644 index 0000000000000..8d79f8d77ea09 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/reduce_all_of.test.ts @@ -0,0 +1,640 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { bundleSpecs } from './bundle_specs'; +import { createOASDocument } from './create_oas_document'; + +describe('OpenAPI Bundler - reduce allOf item', () => { + it('flatten folded allOfs', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + allOf: [ + { + allOf: [ + { + allOf: [ + { $ref: '#/components/schemas/SchemaA' }, + { + type: 'object', + properties: { + fieldA: { type: 'string' }, + }, + required: ['fieldA'], + }, + ], + }, + ], + }, + { $ref: '#/components/schemas/SchemaB' }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + SchemaA: { + type: 'object', + properties: { + fieldX: { type: 'string' }, + }, + }, + SchemaB: { + type: 'object', + properties: { + fieldX: { type: 'string' }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get?.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { + allOf: [ + { $ref: '#/components/schemas/SchemaA' }, + { + type: 'object', + properties: { + fieldA: { type: 'string' }, + }, + required: ['fieldA'], + }, + { $ref: '#/components/schemas/SchemaB' }, + ], + }, + }, + }, + }); + }); + + it('unfolds single allOf item', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + allOf: [ + { + type: 'string', + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get?.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { type: 'string' }, + }, + }, + }); + }); + + it('merges non conflicting allOf object schema items', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + allOf: [ + { $ref: '#/components/schemas/SchemaA' }, + { + type: 'object', + properties: { + fieldA: { + type: 'string', + }, + }, + }, + { $ref: '#/components/schemas/SchemaB' }, + { + type: 'object', + properties: { + fieldB: { + type: 'string', + }, + }, + required: ['fieldB'], + }, + { $ref: '#/components/schemas/SchemaC' }, + { + type: 'object', + properties: { + fieldC: { + type: 'string', + }, + }, + required: ['fieldC'], + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + SchemaA: { + type: 'object', + properties: { + fieldX: { type: 'string' }, + }, + }, + SchemaB: { + type: 'object', + properties: { + fieldY: { type: 'string' }, + }, + }, + SchemaC: { + type: 'object', + properties: { + fieldZ: { type: 'string' }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get?.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { + allOf: [ + { + type: 'object', + properties: { + fieldA: { + type: 'string', + }, + fieldB: { + type: 'string', + }, + fieldC: { + type: 'string', + }, + }, + required: ['fieldB', 'fieldC'], + }, + { $ref: '#/components/schemas/SchemaA' }, + { $ref: '#/components/schemas/SchemaB' }, + { $ref: '#/components/schemas/SchemaC' }, + ], + }, + }, + }, + }); + }); + + it('DOES NOT merge conflicting incompatible allOf object schema items', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + allOf: [ + { + type: 'object', + properties: { + fieldA: { + type: 'string', + }, + }, + }, + { + type: 'object', + properties: { + fieldB: { + type: 'string', + }, + }, + required: ['fieldB'], + }, + { + type: 'object', + properties: { + fieldA: { + type: 'boolean', + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get?.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { + allOf: [ + { + type: 'object', + properties: { + fieldA: { + type: 'string', + }, + }, + }, + { + type: 'object', + properties: { + fieldB: { + type: 'string', + }, + }, + required: ['fieldB'], + }, + { + type: 'object', + properties: { + fieldA: { + type: 'boolean', + }, + }, + }, + ], + }, + }, + }, + }); + }); + + it('merges allOf object schema items with inlined references', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + allOf: [ + { + type: 'string', + enum: ['value1'], + }, + { + type: 'object', + properties: { + fieldA: { + type: 'string', + }, + }, + }, + { + $ref: '#/components/schemas/SchemaA', + }, + { + type: 'object', + properties: { + fieldB: { + type: 'string', + }, + }, + required: ['fieldB'], + }, + { + $ref: '#/components/schemas/SchemaAToInline', + }, + { + $ref: '#/components/schemas/SchemaB', + }, + { + type: 'object', + properties: { + stringField: { + type: 'string', + }, + }, + required: ['stringField'], + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + SchemaAToInline: { + // @ts-expect-error OpenAPIV3.Document doesn't allow to add custom props to components.schemas + 'x-inline': true, + allOf: [ + { + type: 'string', + enum: ['SchemaAToInline-value1'], + }, + { + type: 'object', + properties: { + enumField: { + type: 'string', + enum: ['SchemaAToInline-value2'], + }, + integerField: { + type: 'integer', + minimum: 1, + }, + }, + }, + { + $ref: './common.schema.yaml#/components/schemas/SchemaBToInline', + }, + ], + }, + SchemaA: { + type: 'object', + properties: { + fieldX: { type: 'string' }, + }, + }, + SchemaB: { + type: 'object', + properties: { + fieldY: { type: 'string' }, + }, + }, + }, + }, + }); + const commonSpec = createOASDocument({ + components: { + schemas: { + SchemaBToInline: { + // @ts-expect-error OpenAPIV3.Document doesn't allow to add custom props to components.schemas + 'x-inline': true, + allOf: [ + { + type: 'string', + enum: ['SchemaBToInline-value1', 'SchemaBToInline-value2'], + }, + { + type: 'object', + properties: { + fieldD: { + type: 'string', + }, + fieldE: { + type: 'string', + }, + }, + required: ['fieldE'], + }, + ], + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + common: commonSpec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get?.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { + allOf: [ + { + type: 'object', + properties: { + fieldA: { + type: 'string', + }, + fieldB: { + type: 'string', + }, + enumField: { + type: 'string', + enum: ['SchemaAToInline-value2'], + }, + integerField: { + type: 'integer', + minimum: 1, + }, + fieldD: { + type: 'string', + }, + fieldE: { + type: 'string', + }, + stringField: { + type: 'string', + }, + }, + required: ['fieldB', 'fieldE', 'stringField'], + }, + { + type: 'string', + enum: ['value1'], + }, + { + $ref: '#/components/schemas/SchemaA', + }, + { + type: 'string', + enum: ['SchemaAToInline-value1'], + }, + { + type: 'string', + enum: ['SchemaBToInline-value1', 'SchemaBToInline-value2'], + }, + { + $ref: '#/components/schemas/SchemaB', + }, + ], + }, + }, + }, + }); + }); + + it('merges allOf object schema items inlined in different document branches with extra field', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + responseSchemaFieldA: { + $ref: '#/components/schemas/SchemaToInline', + }, + responseSchemaFieldB: { + $ref: '#/components/schemas/MySchema', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + MySchema: { + allOf: [ + { + $ref: '#/components/schemas/SchemaToInline', + }, + { + type: 'object', + properties: { + mySchemaSubfield: { + type: 'boolean', + }, + }, + }, + ], + }, + SchemaToInline: { + // @ts-expect-error OpenAPIV3.Document doesn't allow to add custom props to components.schemas + 'x-inline': true, + allOf: [ + { + type: 'object', + properties: { + SchemaToInlineField1: { + type: 'string', + }, + }, + }, + { + type: 'object', + properties: { + SchemaToInlineField2: { + type: 'number', + }, + }, + required: ['field2'], + }, + ], + }, + }, + }, + }); + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get?.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + responseSchemaFieldA: { + type: 'object', + properties: { + SchemaToInlineField1: { + type: 'string', + }, + SchemaToInlineField2: { + type: 'number', + }, + }, + required: ['field2'], + }, + responseSchemaFieldB: { + $ref: '#/components/schemas/MySchema', + }, + }, + }, + }, + }, + }); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/__test__/remove_props.test.ts b/packages/kbn-openapi-bundler/src/__test__/remove_props.test.ts new file mode 100644 index 0000000000000..d9ab67386f3c2 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/remove_props.test.ts @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { OpenAPIV3 } from 'openapi-types'; +import { bundleSpecs } from './bundle_specs'; +import { createOASDocument } from './create_oas_document'; + +describe('OpenAPI Bundler - remove custom x- props', () => { + it('removes "x-codegen-enabled" property', async () => { + const spec1 = createOASDocument({ + paths: { + '/api/some_api': { + get: { + // @ts-expect-error custom prop + 'x-codegen-enabled': true, + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + field1: { + type: 'integer', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + const spec2 = createOASDocument({ + paths: { + '/api/some_api': { + post: { + // @ts-expect-error custom prop + 'x-codegen-enabled': false, + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + field2: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + const spec3 = createOASDocument({ + paths: { + '/api/some_api': { + put: { + // @ts-expect-error custom prop + 'x-codegen-enabled': undefined, + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + field2: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec1, + 2: spec2, + 3: spec3, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get).not.toMatchObject({ + 'x-codegen-enabled': expect.anything(), + }); + expect(bundledSpec.paths['/api/some_api']!.post).not.toMatchObject({ + 'x-codegen-enabled': expect.anything(), + }); + expect(bundledSpec.paths['/api/some_api']!.put).not.toMatchObject({ + 'x-codegen-enabled': expect.anything(), + }); + }); + + it('removes "x-inline" property', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + operationId: 'TestEndpointGet', + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SchemaToInline', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + SchemaToInline: { + // @ts-expect-error OpenAPIV3.Document doesn't allow to add custom props to components.schemas + 'x-inline': true, + type: 'string', + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + const bundledSchema = ( + bundledSpec.paths['/api/some_api']!.get?.responses['200'] as OpenAPIV3.ResponseObject + ).content!['application/json'].schema; + + expect(bundledSchema).not.toMatchObject({ + 'x-inline': expect.anything(), + }); + }); + + it('removes "x-modify" property', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + operationId: 'TestEndpointGet', + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + // @ts-expect-error custom prop + 'x-modify': 'required', + type: 'object', + properties: { + field1: { + type: 'string', + }, + field2: { + type: 'integer', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + const bundledSchema = ( + bundledSpec.paths['/api/some_api']!.get?.responses['200'] as OpenAPIV3.ResponseObject + ).content!['application/json'].schema; + + expect(bundledSchema).not.toMatchObject({ + 'x-modify': expect.anything(), + }); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/__test__/self_recursive_ref/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/self_recursive_ref/expected.yaml deleted file mode 100644 index 2b75720a069b1..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/self_recursive_ref/expected.yaml +++ /dev/null @@ -1,24 +0,0 @@ -spec.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './shared_components.schema.yaml#/components/schemas/CircularTestSchema' - -shared_components.schema.yaml: - components: - schemas: - CircularTestSchema: - type: string - data: - $ref: '#/components/schemas/CircularTestSchema' diff --git a/packages/kbn-openapi-bundler/src/__test__/self_recursive_ref/spec.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/self_recursive_ref/spec.schema.yaml deleted file mode 100644 index d90a117818455..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/self_recursive_ref/spec.schema.yaml +++ /dev/null @@ -1,22 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/CircularTestSchema' - -components: - schemas: - CircularTestSchema: - type: string - data: - $ref: '#/components/schemas/CircularTestSchema' diff --git a/packages/kbn-openapi-bundler/src/__test__/skip_internal/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/skip_internal/expected.yaml deleted file mode 100644 index d8f6d6cb474bf..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/skip_internal/expected.yaml +++ /dev/null @@ -1,31 +0,0 @@ -spec.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - anyOf: - - $ref: './shared_components.schema.yaml#/components/schemas/TestSchema1' - - type: object - -shared_components.schema.yaml: - components: - schemas: - TestSchema1: - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 diff --git a/packages/kbn-openapi-bundler/src/__test__/skip_internal/spec.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/skip_internal/spec.schema.yaml deleted file mode 100644 index 1d172978a4240..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/skip_internal/spec.schema.yaml +++ /dev/null @@ -1,57 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TestSchema1' - - $ref: '#/components/schemas/TestSchema2' - x-internal: true - - type: object - properties: - x-internal: true - field1: - $ref: '#/components/schemas/TestSchema3' - -components: - schemas: - TestSchema1: - # x-internal is not supported here - # x-internal: true - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - - TestSchema2: - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 - - TestSchema3: - type: object - properties: - field1: - type: string - enum: [value1] - field2: - type: integer - minimum: 1 diff --git a/packages/kbn-openapi-bundler/src/__test__/skip_internal_endpoint/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/skip_internal_endpoint/expected.yaml deleted file mode 100644 index 3015eb607287a..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/skip_internal_endpoint/expected.yaml +++ /dev/null @@ -1,22 +0,0 @@ -spec1.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: integer - -shared_components.schema.yaml: - components: {} diff --git a/packages/kbn-openapi-bundler/src/__test__/skip_internal_endpoint/spec1.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/skip_internal_endpoint/spec1.schema.yaml deleted file mode 100644 index 5b7f6a8718fcf..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/skip_internal_endpoint/spec1.schema.yaml +++ /dev/null @@ -1,18 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: integer diff --git a/packages/kbn-openapi-bundler/src/__test__/skip_internal_endpoint/spec2.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/skip_internal_endpoint/spec2.schema.yaml deleted file mode 100644 index 5a53977b69100..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/skip_internal_endpoint/spec2.schema.yaml +++ /dev/null @@ -1,18 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint POST - version: '2023-10-31' -paths: - /internal/some_api: - post: - operationId: TestEndpointPost - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field2: - type: string diff --git a/packages/kbn-openapi-bundler/src/__test__/skip_nodes.test.ts b/packages/kbn-openapi-bundler/src/__test__/skip_nodes.test.ts new file mode 100644 index 0000000000000..35b1de2b3a0bc --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/skip_nodes.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { bundleSpecs } from './bundle_specs'; +import { createOASDocument } from './create_oas_document'; + +describe('OpenAPI Bundler - skip nodes like internal endpoints', () => { + it('skips nodes with x-internal property', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + anyOf: [ + { + $ref: '#/components/schemas/TestSchema1', + }, + { + $ref: '#/components/schemas/TestSchema2', + // @ts-expect-error custom prop + 'x-internal': true, + }, + { + type: 'object', + properties: { + field1: { + type: 'string', + }, + internalField: { + 'x-internal': true, + type: 'string', + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + TestSchema1: { + type: 'object', + properties: { + field1: { + type: 'string', + enum: ['value1'], + }, + field2: { + type: 'integer', + minimum: 1, + }, + }, + }, + TestSchema2: { + type: 'string', + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get!.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { + anyOf: [ + { + $ref: '#/components/schemas/TestSchema1', + }, + { + type: 'object', + properties: { + field1: { + type: 'string', + }, + }, + }, + ], + }, + }, + }, + }); + }); + + it('skips endpoints starting with /internal', async () => { + const spec1 = createOASDocument({ + paths: { + '/api/some_api': { + get: { + operationId: 'TestEndpointGet', + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + field1: { + type: 'integer', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + const spec2 = createOASDocument({ + paths: { + '/internal/some_api': { + post: { + operationId: 'TestEndpointPost', + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + field2: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec1, + 2: spec2, + }) + ); + + expect(Object.keys(bundledSpec.paths)).not.toContain('/internal/some_api'); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/__test__/spec_with_external_ref/common.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/spec_with_external_ref/common.schema.yaml deleted file mode 100644 index b710c4e8b114b..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/spec_with_external_ref/common.schema.yaml +++ /dev/null @@ -1,13 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint - version: '2023-10-31' -paths: {} - -components: - schemas: - TestSchema: - type: string - enum: - - value1 - - value2 diff --git a/packages/kbn-openapi-bundler/src/__test__/spec_with_external_ref/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/spec_with_external_ref/expected.yaml deleted file mode 100644 index 48c9045d62ce5..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/spec_with_external_ref/expected.yaml +++ /dev/null @@ -1,25 +0,0 @@ -spec.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './shared_components.schema.yaml#/components/schemas/TestSchema' - -shared_components.schema.yaml: - components: - schemas: - TestSchema: - type: string - enum: - - value1 - - value2 diff --git a/packages/kbn-openapi-bundler/src/__test__/spec_with_local_ref/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/spec_with_local_ref/expected.yaml deleted file mode 100644 index 48c9045d62ce5..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/spec_with_local_ref/expected.yaml +++ /dev/null @@ -1,25 +0,0 @@ -spec.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './shared_components.schema.yaml#/components/schemas/TestSchema' - -shared_components.schema.yaml: - components: - schemas: - TestSchema: - type: string - enum: - - value1 - - value2 diff --git a/packages/kbn-openapi-bundler/src/__test__/spec_with_local_ref/spec.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/spec_with_local_ref/spec.schema.yaml deleted file mode 100644 index 2339d5eb7aa59..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/spec_with_local_ref/spec.schema.yaml +++ /dev/null @@ -1,23 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/TestSchema' - -components: - schemas: - TestSchema: - type: string - enum: - - value1 - - value2 diff --git a/packages/kbn-openapi-bundler/src/__test__/two_simple_specs/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/two_simple_specs/expected.yaml deleted file mode 100644 index dbe9fdb445e86..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/two_simple_specs/expected.yaml +++ /dev/null @@ -1,42 +0,0 @@ -spec1.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: integer - -spec2.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint POST - version: '2023-10-31' - paths: - /api/some_api: - post: - operationId: TestEndpointPost - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field2: - type: string - -shared_components.schema.yaml: - components: {} diff --git a/packages/kbn-openapi-bundler/src/__test__/two_simple_specs/spec1.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/two_simple_specs/spec1.schema.yaml deleted file mode 100644 index 5b7f6a8718fcf..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/two_simple_specs/spec1.schema.yaml +++ /dev/null @@ -1,18 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field1: - type: integer diff --git a/packages/kbn-openapi-bundler/src/__test__/two_simple_specs/spec2.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/two_simple_specs/spec2.schema.yaml deleted file mode 100644 index c3ba67e0b46ea..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/two_simple_specs/spec2.schema.yaml +++ /dev/null @@ -1,18 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint POST - version: '2023-10-31' -paths: - /api/some_api: - post: - operationId: TestEndpointPost - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - field2: - type: string diff --git a/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/common.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/common.schema.yaml deleted file mode 100644 index b710c4e8b114b..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/common.schema.yaml +++ /dev/null @@ -1,13 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint - version: '2023-10-31' -paths: {} - -components: - schemas: - TestSchema: - type: string - enum: - - value1 - - value2 diff --git a/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/expected.yaml b/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/expected.yaml deleted file mode 100644 index d3040ae511b2c..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/expected.yaml +++ /dev/null @@ -1,42 +0,0 @@ -spec1.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint GET - version: '2023-10-31' - paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './shared_components.schema.yaml#/components/schemas/TestSchema' - -spec2.schema.yaml: - openapi: 3.0.3 - info: - title: Test endpoint POST - version: '2023-10-31' - paths: - /api/some_api: - post: - operationId: TestEndpointPost - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './shared_components.schema.yaml#/components/schemas/TestSchema' - -shared_components.schema.yaml: - components: - schemas: - TestSchema: - type: string - enum: - - value1 - - value2 diff --git a/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/spec1.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/spec1.schema.yaml deleted file mode 100644 index c08570d69311c..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/spec1.schema.yaml +++ /dev/null @@ -1,15 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint GET - version: '2023-10-31' -paths: - /api/some_api: - get: - operationId: TestEndpointGet - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './common.schema.yaml#/components/schemas/TestSchema' diff --git a/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/spec2.schema.yaml b/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/spec2.schema.yaml deleted file mode 100644 index 9dec5566875bb..0000000000000 --- a/packages/kbn-openapi-bundler/src/__test__/two_specs_with_external_ref/spec2.schema.yaml +++ /dev/null @@ -1,15 +0,0 @@ -openapi: 3.0.3 -info: - title: Test endpoint POST - version: '2023-10-31' -paths: - /api/some_api: - post: - operationId: TestEndpointPost - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: './common.schema.yaml#/components/schemas/TestSchema' diff --git a/packages/kbn-openapi-bundler/src/__test__/x_modify.test.ts b/packages/kbn-openapi-bundler/src/__test__/x_modify.test.ts new file mode 100644 index 0000000000000..cdc53e8369345 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/__test__/x_modify.test.ts @@ -0,0 +1,340 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { bundleSpecs } from './bundle_specs'; +import { createOASDocument } from './create_oas_document'; + +describe('OpenAPI Bundler - x-modify', () => { + it('inlines references with x-modify property', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + anyOf: [ + { + $ref: '#/components/schemas/SchemaWithRequiredFields', + // @ts-expect-error custom prop + 'x-modify': 'partial', + }, + { + $ref: '#/components/schemas/SchemaWithOptionalFields', + 'x-modify': 'required', + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + SchemaWithRequiredFields: { + type: 'object', + properties: { + fieldA: { + type: 'string', + enum: ['value1'], + }, + fieldB: { + type: 'integer', + minimum: 1, + }, + }, + required: ['fieldA', 'fieldB'], + }, + SchemaWithOptionalFields: { + type: 'object', + properties: { + fieldC: { + type: 'string', + enum: ['value1'], + }, + fieldD: { + type: 'integer', + minimum: 1, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get!.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: { + anyOf: [ + { + type: 'object', + properties: { + fieldA: { + type: 'string', + enum: ['value1'], + }, + fieldB: { + type: 'integer', + minimum: 1, + }, + }, + }, + { + type: 'object', + properties: { + fieldC: { + type: 'string', + enum: ['value1'], + }, + fieldD: { + type: 'integer', + minimum: 1, + }, + }, + required: ['fieldC', 'fieldD'], + }, + ], + }, + }, + }, + }); + }); + + it('makes properties in an object schema node partial', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + // @ts-expect-error custom prop + 'x-modify': 'partial', + type: 'object', + properties: { + fieldA: { + type: 'string', + enum: ['value1'], + }, + fieldB: { + type: 'integer', + minimum: 1, + }, + }, + required: ['fieldA', 'fieldB'], + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get!.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: expect.not.objectContaining({ + required: expect.anything(), + }), + }, + }, + }); + }); + + it('makes properties in a referenced object schema node partial', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TestSchema', + // @ts-expect-error custom prop + 'x-modify': 'partial', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + TestSchema: { + type: 'object', + properties: { + fieldA: { + type: 'string', + enum: ['value1'], + }, + fieldB: { + type: 'integer', + minimum: 1, + }, + }, + required: ['fieldA', 'fieldB'], + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get!.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: expect.not.objectContaining({ + required: expect.anything(), + }), + }, + }, + }); + }); + + it('makes properties in an object schema node required', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + // @ts-expect-error custom prop + 'x-modify': 'required', + type: 'object', + properties: { + fieldA: { + type: 'string', + enum: ['value1'], + }, + fieldB: { + type: 'integer', + minimum: 1, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get!.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: expect.objectContaining({ + required: ['fieldA', 'fieldB'], + }), + }, + }, + }); + }); + + it('makes properties in a referenced object schema node required', async () => { + const spec = createOASDocument({ + paths: { + '/api/some_api': { + get: { + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TestSchema', + // @ts-expect-error custom prop + 'x-modify': 'required', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + TestSchema: { + type: 'object', + properties: { + fieldA: { + type: 'string', + enum: ['value1'], + }, + fieldB: { + type: 'integer', + minimum: 1, + }, + }, + }, + }, + }, + }); + + const [bundledSpec] = Object.values( + await bundleSpecs({ + 1: spec, + }) + ); + + expect(bundledSpec.paths['/api/some_api']!.get!.responses['200']).toMatchObject({ + content: { + 'application/json': { + schema: expect.objectContaining({ + required: ['fieldA', 'fieldB'], + }), + }, + }, + }); + }); +}); diff --git a/packages/kbn-openapi-bundler/src/bundler/bundle_document.ts b/packages/kbn-openapi-bundler/src/bundler/bundle_document.ts index 1f6884a87f677..502ede318ca6d 100644 --- a/packages/kbn-openapi-bundler/src/bundler/bundle_document.ts +++ b/packages/kbn-openapi-bundler/src/bundler/bundle_document.ts @@ -19,6 +19,11 @@ import { createModifyRequiredProcessor } from './document_processors/modify_requ import { X_CODEGEN_ENABLED, X_INLINE, X_INTERNAL, X_MODIFY } from './known_custom_props'; import { RemoveUnusedComponentsProcessor } from './document_processors/remove_unused_components'; import { isPlainObjectType } from '../utils/is_plain_object_type'; +import { + createFlattenFoldedAllOfItemsProcessor, + createMergeNonConflictingAllOfItemsProcessor, + createUnfoldSingleAllOfItemProcessor, +} from './document_processors/reduce_all_of_items'; export class SkipException extends Error { constructor(public documentPath: string, message: string) { @@ -72,7 +77,10 @@ export async function bundleDocument(absoluteDocumentPath: string): Promise `schemas` -> `SomeSchema` and `$ref` property's - * values is updated to `#/components/schemas/SomeSchema`. + * values are updated to be local e.g. `#/components/schemas/SomeSchema`. * - * Conditional dereference means inlining references when `inliningPredicate()` returns `true`. If `inliningPredicate` - * is not passed only bundling happens. + * Some references get inlined based on a condition (conditional dereference). It's controlled by inlining + * property whose value should be `true`. `inliningPropName` specifies inlining property name e.g. `x-inline`. + * Nodes having `x-inline: true` will be inlined. */ -export class BundleRefProcessor { - private refs: ResolvedRef[] = []; +export class BundleRefProcessor implements DocumentNodeProcessor { + private refs = new Map(); + private nodesToInline = new Set>(); constructor(private inliningPropName: string) {} - ref(node: RefNode, resolvedRef: ResolvedRef, context: TraverseDocumentContext): void { - if (!resolvedRef.pointer.startsWith('/components/schemas')) { - throw new Error(`$ref pointer must start with "/components/schemas"`); + onNodeEnter(node: Readonly): void { + if (hasProp(node, this.inliningPropName, true)) { + this.nodesToInline.add(node); } + } - if ( - hasProp(node, this.inliningPropName, true) || - hasProp(resolvedRef.refNode, this.inliningPropName, true) - ) { - inlineRef(node, resolvedRef); + onRefNodeLeave(node: RefNode, resolvedRef: ResolvedRef, context: TraverseDocumentContext): void { + if (!resolvedRef.pointer.startsWith('/components')) { + throw new Error( + `$ref pointer ${chalk.yellow( + resolvedRef.pointer + )} must start with "/components" at ${chalk.bold(resolvedRef.absolutePath)}` + ); + } - delete node[this.inliningPropName]; + if (this.nodesToInline.has(node) || this.nodesToInline.has(resolvedRef.refNode)) { + inlineRef(node, resolvedRef); } else { const rootDocument = this.extractRootDocument(context); @@ -46,16 +63,34 @@ export class BundleRefProcessor { rootDocument.components = {}; } + const ref = this.refs.get(resolvedRef.pointer); + + if (ref && !deepEqual(ref.refNode, resolvedRef.refNode)) { + const documentAbsolutePath = + this.extractParentContext(context).resolvedDocument.absolutePath; + + throw new Error( + `❌ Unable to bundle ${chalk.bold( + documentAbsolutePath + )} due to conflicts in references. Schema ${chalk.yellow( + ref.pointer + )} is defined in ${chalk.blue(ref.absolutePath)} and in ${chalk.magenta( + resolvedRef.absolutePath + )} but has not matching definitions.` + ); + } + node.$ref = this.saveComponent( resolvedRef, rootDocument.components as Record ); - this.refs.push(resolvedRef); + + this.refs.set(resolvedRef.pointer, resolvedRef); } } - getBundledRefs(): ResolvedRef[] { - return this.refs; + getBundledRefs(): IterableIterator { + return this.refs.values(); } private saveComponent(ref: ResolvedRef, components: Record): string { @@ -64,11 +99,15 @@ export class BundleRefProcessor { return `#${ref.pointer}`; } - private extractRootDocument(context: TraverseDocumentContext): Document { + private extractParentContext(context: TraverseDocumentContext): TraverseRootDocumentContext { while (isChildContext(context)) { context = context.parentContext; } - return context.resolvedDocument.document; + return context; + } + + private extractRootDocument(context: TraverseDocumentContext): Document { + return this.extractParentContext(context).resolvedDocument.document; } } diff --git a/packages/kbn-openapi-bundler/src/bundler/document_processors/modify_partial.ts b/packages/kbn-openapi-bundler/src/bundler/document_processors/modify_partial.ts index 13c876b7579ca..5d667eff1bc80 100644 --- a/packages/kbn-openapi-bundler/src/bundler/document_processors/modify_partial.ts +++ b/packages/kbn-openapi-bundler/src/bundler/document_processors/modify_partial.ts @@ -17,7 +17,7 @@ import { X_MODIFY } from '../known_custom_props'; */ export function createModifyPartialProcessor(): DocumentNodeProcessor { return { - ref(node, resolvedRef) { + onRefNodeLeave(node, resolvedRef) { if (!hasProp(node, X_MODIFY, 'partial')) { return; } @@ -27,7 +27,7 @@ export function createModifyPartialProcessor(): DocumentNodeProcessor { delete node.required; }, - leave(node) { + onNodeLeave(node) { if (!hasProp(node, X_MODIFY, 'partial')) { return; } diff --git a/packages/kbn-openapi-bundler/src/bundler/document_processors/modify_required.ts b/packages/kbn-openapi-bundler/src/bundler/document_processors/modify_required.ts index 14a9ac2ea25c6..769672d64a39d 100644 --- a/packages/kbn-openapi-bundler/src/bundler/document_processors/modify_required.ts +++ b/packages/kbn-openapi-bundler/src/bundler/document_processors/modify_required.ts @@ -20,7 +20,7 @@ import { inlineRef } from './utils/inline_ref'; */ export function createModifyRequiredProcessor(): DocumentNodeProcessor { return { - ref(node, resolvedRef) { + onRefNodeLeave(node, resolvedRef) { if (!hasProp(node, X_MODIFY, 'required')) { return; } @@ -48,7 +48,7 @@ export function createModifyRequiredProcessor(): DocumentNodeProcessor { node.required = Object.keys(resolvedRef.refNode.properties); }, - leave(node) { + onNodeLeave(node) { if (!hasProp(node, X_MODIFY, 'required')) { return; } diff --git a/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/flatten_folded_all_of_items.ts b/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/flatten_folded_all_of_items.ts new file mode 100644 index 0000000000000..dcf4b82a42af5 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/flatten_folded_all_of_items.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DocumentNodeProcessor } from '../../types'; + +/** + * Creates a node processor to flatten folded `allOf` items. Folded means `allOf` has items + * which are another `allOf`s instead of being e.g. object schemas. + * + * Folded `allOf` schemas is usually a result of inlining references. + * + * Example: + * + * The following folded `allOf`s + * + * ```yaml + * allOf: + * - allOf: + * - type: object + * properties: + * fieldA: + * $ref: '#/components/schemas/FieldA' + * - type: object + * properties: + * fieldB: + * type: string + * ``` + * + * will be transformed to + * + * ```yaml + * allOf: + * - type: object + * properties: + * fieldA: + * $ref: '#/components/schemas/FieldA' + * - type: object + * properties: + * fieldB: + * type: string + * ``` + * + */ +export function createFlattenFoldedAllOfItemsProcessor(): DocumentNodeProcessor { + return { + onNodeLeave(node) { + if (!('allOf' in node) || !Array.isArray(node.allOf)) { + return; + } + + node.allOf = node.allOf.flatMap((childNode) => + 'allOf' in childNode ? childNode.allOf : childNode + ); + }, + }; +} diff --git a/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/index.ts b/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/index.ts new file mode 100644 index 0000000000000..acfe16cc95c5e --- /dev/null +++ b/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './flatten_folded_all_of_items'; +export * from './merge_non_conflicting_all_of_items'; +export * from './unfold_single_all_of_item'; diff --git a/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/merge_non_conflicting_all_of_items.ts b/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/merge_non_conflicting_all_of_items.ts new file mode 100644 index 0000000000000..87895be952b96 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/merge_non_conflicting_all_of_items.ts @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { omit } from 'lodash'; +import deepEqual from 'fast-deep-equal'; +import { OpenAPIV3 } from 'openapi-types'; +import { isPlainObjectType } from '../../../utils/is_plain_object_type'; +import { DocumentNodeProcessor } from '../../types'; + +type MergedObjectSchema = Required> & + Pick; + +/** + * Creates a node processor to merge object schema definitions when there are no conflicts + * between them. + * + * After inlining references or any other transformations a schema may have `allOf` + * with multiple object schema items. Object schema has `properties` field describing object + * properties and optional `required` field to say which object properties are not optional. + * + * Conflicts between object schemas do now allow merge them. The following conflicts may appear + * + * - Two or more object schemas define the same named object field but definition is different + * - Some of object schemas have optional properties like `readOnly` + * - Two or more object schemas have conflicting optional properties values + * + * Example: + * + * The following `allOf` containing multiple object schemas + * + * ```yaml + * allOf: + * - type: object + * properties: + * fieldA: + * $ref: '#/components/schemas/FieldA' + * - type: object + * properties: + * fieldB: + * type: string + * ``` + * + * will be transformed to + * + * ```yaml + * allOf: + * - type: object + * properties: + * fieldA: + * $ref: '#/components/schemas/FieldA' + * fieldB: + * type: string + * ``` + */ +export function createMergeNonConflictingAllOfItemsProcessor(): DocumentNodeProcessor { + return { + onNodeLeave(allOfNode) { + if ( + !('allOf' in allOfNode) || + !Array.isArray(allOfNode.allOf) || + !canMergeObjectSchemas(allOfNode.allOf) + ) { + return; + } + + const resultItems: [ + MergedObjectSchema, + ...Array + ] = [ + { + type: 'object', + properties: {}, + }, + ]; + const mergedRequired = new Set(); + + for (const item of allOfNode.allOf) { + if (!isObjectNode(item) || !isPlainObjectType(item.properties)) { + resultItems.push(item); + continue; + } + + Object.assign(resultItems[0].properties, item.properties); + + for (const requiredField of item.required ?? []) { + mergedRequired.add(requiredField); + } + } + + if (mergedRequired.size > 0) { + resultItems[0].required = Array.from(mergedRequired); + } + + allOfNode.allOf = resultItems; + }, + }; +} + +/** + * Object schemas can be merged when + * + * - as minimum there are two object schemas + * - object schemas DO NOT contain conflicting fields (same name but different definition) + * - object schemas DO NOT contain fields besides `type`, `properties` and `required` + * + */ +function canMergeObjectSchemas(schemas: OpenAPIV3.SchemaObject[]): boolean { + const props = new Map(); + let objectSchemasCounter = 0; + + for (const node of schemas) { + if (!isObjectNode(node) || !isPlainObjectType(node.properties)) { + continue; + } + + if (getObjectSchemaExtraFieldNames(node).size > 0) { + return false; + } + + const nodePropNames = Object.keys(node.properties); + + for (const nodePropName of nodePropNames) { + const propSchema = props.get(nodePropName); + + if (propSchema && !deepEqual(propSchema, node.properties[nodePropName])) { + return false; + } + + props.set(nodePropName, node.properties[nodePropName]); + } + + objectSchemasCounter++; + } + + return objectSchemasCounter > 1; +} + +function getObjectSchemaExtraFieldNames(schema: OpenAPIV3.SchemaObject): Set { + return new Set(Object.keys(omit(schema, ['type', 'properties', 'required']))); +} + +function isObjectNode(node: unknown): boolean { + return isPlainObjectType(node) && node.type === 'object'; +} diff --git a/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/unfold_single_all_of_item.ts b/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/unfold_single_all_of_item.ts new file mode 100644 index 0000000000000..67a056091db4c --- /dev/null +++ b/packages/kbn-openapi-bundler/src/bundler/document_processors/reduce_all_of_items/unfold_single_all_of_item.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DocumentNodeProcessor } from '../../types'; + +/** + * Created a node processor to remove/unfold `allOf` with only single item. + * + * While a schema can be defined like that the most often reason why `allOf` has + * only one item is flattening folded `allOf` items via `flattenFoldedAllOfItems` + * node processor. + * + * Example: + * + * The following single item `allOf` + * + * ```yaml + * allOf: + * - type: object + * properties: + * fieldA: + * $ref: '#/components/schemas/FieldA' + * ``` + * + * will be transformed to + * + * ```yaml + * type: object + * properties: + * fieldA: + * $ref: '#/components/schemas/FieldA' + * ``` + * + */ +export function createUnfoldSingleAllOfItemProcessor(): DocumentNodeProcessor { + return { + onNodeLeave(node) { + if (!('allOf' in node) || !Array.isArray(node.allOf) || node.allOf.length > 1) { + return; + } + + Object.assign(node, node.allOf[0]); + delete node.allOf; + }, + }; +} diff --git a/packages/kbn-openapi-bundler/src/bundler/document_processors/remove_props.ts b/packages/kbn-openapi-bundler/src/bundler/document_processors/remove_props.ts index 616d9db11f55e..02fb2036e2a0c 100644 --- a/packages/kbn-openapi-bundler/src/bundler/document_processors/remove_props.ts +++ b/packages/kbn-openapi-bundler/src/bundler/document_processors/remove_props.ts @@ -14,13 +14,13 @@ import { DocumentNodeProcessor } from '../types'; */ export function createRemovePropsProcessor(propNames: string[]): DocumentNodeProcessor { return { - leave(node) { + onNodeLeave(node) { if (!isPlainObjectType(node)) { return; } for (const propName of propNames) { - if (!node[propName]) { + if (!Object.hasOwn(node, propName)) { continue; } diff --git a/packages/kbn-openapi-bundler/src/bundler/document_processors/remove_unused_components.ts b/packages/kbn-openapi-bundler/src/bundler/document_processors/remove_unused_components.ts index 1f5053d4667fe..393f986ec5a1a 100644 --- a/packages/kbn-openapi-bundler/src/bundler/document_processors/remove_unused_components.ts +++ b/packages/kbn-openapi-bundler/src/bundler/document_processors/remove_unused_components.ts @@ -8,7 +8,7 @@ import { hasProp } from '../../utils/has_prop'; import { isPlainObjectType } from '../../utils/is_plain_object_type'; -import { PlainObjectNode, ResolvedRef } from '../types'; +import { DocumentNodeProcessor, PlainObjectNode, ResolvedRef } from '../types'; /** * Helps to remove unused components. @@ -17,10 +17,10 @@ import { PlainObjectNode, ResolvedRef } from '../types'; * and then `removeUnusedComponents()` should be invoked after document processing to perform * actual unused components deletion. */ -export class RemoveUnusedComponentsProcessor { +export class RemoveUnusedComponentsProcessor implements DocumentNodeProcessor { private refs = new Set(); - ref(node: unknown, resolvedRef: ResolvedRef): void { + onRefNodeLeave(node: unknown, resolvedRef: ResolvedRef): void { // If the reference has been inlined by one of the previous processors skip it if (!hasProp(node, '$ref')) { return; diff --git a/packages/kbn-openapi-bundler/src/bundler/document_processors/skip_internal_path.ts b/packages/kbn-openapi-bundler/src/bundler/document_processors/skip_internal_path.ts index 42769eab7a68a..280002ce13890 100644 --- a/packages/kbn-openapi-bundler/src/bundler/document_processors/skip_internal_path.ts +++ b/packages/kbn-openapi-bundler/src/bundler/document_processors/skip_internal_path.ts @@ -13,7 +13,7 @@ import { DocumentNodeProcessor } from '../types'; */ export function createSkipInternalPathProcessor(skipPathPrefix: string): DocumentNodeProcessor { return { - enter(_, context) { + shouldRemove(_, context) { if (typeof context.parentKey === 'number') { return false; } diff --git a/packages/kbn-openapi-bundler/src/bundler/document_processors/skip_node_with_internal_prop.ts b/packages/kbn-openapi-bundler/src/bundler/document_processors/skip_node_with_internal_prop.ts index 4931036bcd1bc..b674cfb8c0b9c 100644 --- a/packages/kbn-openapi-bundler/src/bundler/document_processors/skip_node_with_internal_prop.ts +++ b/packages/kbn-openapi-bundler/src/bundler/document_processors/skip_node_with_internal_prop.ts @@ -16,6 +16,6 @@ export function createSkipNodeWithInternalPropProcessor( skipProperty: string ): DocumentNodeProcessor { return { - enter: (node) => skipProperty in node, + shouldRemove: (node) => skipProperty in node, }; } diff --git a/packages/kbn-openapi-bundler/src/bundler/document_processors/utils/inline_ref.ts b/packages/kbn-openapi-bundler/src/bundler/document_processors/utils/inline_ref.ts index 3106bf9cbc95d..fac6c519980b9 100644 --- a/packages/kbn-openapi-bundler/src/bundler/document_processors/utils/inline_ref.ts +++ b/packages/kbn-openapi-bundler/src/bundler/document_processors/utils/inline_ref.ts @@ -6,15 +6,11 @@ * Side Public License, v 1. */ -import { cloneDeep } from 'lodash'; import { DocumentNode, ResolvedRef } from '../../types'; import { InlinableRefNode } from '../types'; export function inlineRef(node: DocumentNode, resolvedRef: ResolvedRef): void { - // Make sure unwanted side effects don't happen when child nodes are processed - const deepClone = cloneDeep(resolvedRef.refNode); - - Object.assign(node, deepClone); + Object.assign(node, resolvedRef.refNode); delete (node as InlinableRefNode).$ref; } diff --git a/packages/kbn-openapi-bundler/src/bundler/merge_documents.ts b/packages/kbn-openapi-bundler/src/bundler/merge_documents.ts deleted file mode 100644 index e27253cefc1c9..0000000000000 --- a/packages/kbn-openapi-bundler/src/bundler/merge_documents.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import deepEqual from 'fast-deep-equal'; -import { basename, dirname, join } from 'path'; -import chalk from 'chalk'; -import { parseRef } from '../utils/parse_ref'; -import { insertRefByPointer } from '../utils/insert_by_json_pointer'; -import { DocumentNodeProcessor, PlainObjectNode, ResolvedDocument, ResolvedRef } from './types'; -import { BundledDocument } from './bundle_document'; -import { processDocument } from './process_document'; - -type MergedDocuments = Record; - -type MergedResult = Record; - -const SHARED_COMPONENTS_FILE_NAME = 'shared_components.schema.yaml'; - -export async function mergeDocuments(bundledDocuments: BundledDocument[]): Promise { - const mergedDocuments: MergedDocuments = {}; - const componentsMap = new Map(); - - for (const bundledDocument of bundledDocuments) { - mergeRefsToMap(bundledDocument.bundledRefs, componentsMap); - - delete bundledDocument.document.components; - - await setRefsFileName(bundledDocument, SHARED_COMPONENTS_FILE_NAME); - mergeDocument(bundledDocument, mergedDocuments); - } - - const result: MergedResult = {}; - - for (const fileName of Object.keys(mergedDocuments)) { - result[fileName] = mergedDocuments[fileName].document; - } - - result[SHARED_COMPONENTS_FILE_NAME] = { - components: componentsMapToComponents(componentsMap), - }; - - return result; -} - -function mergeDocument(resolvedDocument: ResolvedDocument, mergeResult: MergedDocuments): void { - const fileName = basename(resolvedDocument.absolutePath); - - if (!mergeResult[fileName]) { - mergeResult[fileName] = resolvedDocument; - return; - } - - const nonConflictFileName = generateNonConflictingFilePath( - resolvedDocument.absolutePath, - mergeResult - ); - - mergeResult[nonConflictFileName] = resolvedDocument; -} - -function generateNonConflictingFilePath( - documentAbsolutePath: string, - mergeResult: MergedDocuments -): string { - let pathToDocument = dirname(documentAbsolutePath); - let suggestedName = basename(documentAbsolutePath); - - while (mergeResult[suggestedName]) { - suggestedName = `${basename(pathToDocument)}_${suggestedName}`; - pathToDocument = join(pathToDocument, '..'); - } - - return suggestedName; -} - -function mergeRefsToMap(bundledRefs: ResolvedRef[], componentsMap: Map): void { - for (const bundledRef of bundledRefs) { - const existingRef = componentsMap.get(bundledRef.pointer); - - if (!existingRef) { - componentsMap.set(bundledRef.pointer, bundledRef); - continue; - } - - if (deepEqual(existingRef.refNode, bundledRef.refNode)) { - continue; - } - - throw new Error( - `❌ Unable to bundle documents due to conflicts in references. Schema ${chalk.yellow( - bundledRef.pointer - )} is defined in ${chalk.blue(existingRef.absolutePath)} and in ${chalk.magenta( - bundledRef.absolutePath - )} but has not matching definitions.` - ); - } -} - -function componentsMapToComponents( - componentsMap: Map -): Record { - const result: Record = {}; - - for (const resolvedRef of componentsMap.values()) { - insertRefByPointer(resolvedRef.pointer, resolvedRef.refNode, result); - } - - return result; -} - -async function setRefsFileName( - resolvedDocument: ResolvedDocument, - fileName: string -): Promise { - // We don't need to follow references - const stubRefResolver = { - resolveRef: async (refDocumentAbsolutePath: string, pointer: string): Promise => ({ - absolutePath: refDocumentAbsolutePath, - pointer, - document: resolvedDocument.document, - refNode: {}, - }), - resolveDocument: async (): Promise => ({ - absolutePath: '', - document: resolvedDocument.document, - }), - }; - const setRefFileProcessor: DocumentNodeProcessor = { - ref: (node) => { - const { pointer } = parseRef(node.$ref); - - node.$ref = `./${fileName}#${pointer}`; - }, - }; - - await processDocument(resolvedDocument, stubRefResolver, [setRefFileProcessor]); -} diff --git a/packages/kbn-openapi-bundler/src/bundler/merge_documents/create_blank_oas_document.ts b/packages/kbn-openapi-bundler/src/bundler/merge_documents/create_blank_oas_document.ts new file mode 100644 index 0000000000000..925471719b345 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/bundler/merge_documents/create_blank_oas_document.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { OpenAPIV3 } from 'openapi-types'; + +export function createBlankOpenApiDocument( + oasVersion: string, + info: OpenAPIV3.InfoObject +): OpenAPIV3.Document { + return { + openapi: oasVersion, + info, + servers: [ + { + url: 'http://{kibana_host}:{port}', + variables: { + kibana_host: { + default: 'localhost', + }, + port: { + default: '5601', + }, + }, + }, + ], + security: [ + { + BasicAuth: [], + }, + ], + paths: {}, + components: { + securitySchemes: { + BasicAuth: { + type: 'http', + scheme: 'basic', + }, + }, + }, + }; +} diff --git a/packages/kbn-openapi-bundler/src/bundler/merge_documents/index.ts b/packages/kbn-openapi-bundler/src/bundler/merge_documents/index.ts new file mode 100644 index 0000000000000..554cf7cd2c2c5 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/bundler/merge_documents/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './merge_documents'; diff --git a/packages/kbn-openapi-bundler/src/bundler/merge_documents/merge_documents.ts b/packages/kbn-openapi-bundler/src/bundler/merge_documents/merge_documents.ts new file mode 100644 index 0000000000000..03275dbf3f3de --- /dev/null +++ b/packages/kbn-openapi-bundler/src/bundler/merge_documents/merge_documents.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import chalk from 'chalk'; +import { OpenAPIV3 } from 'openapi-types'; +import { logger } from '../../logger'; +import { BundledDocument } from '../bundle_document'; +import { mergePaths } from './merge_paths'; +import { mergeSharedComponents } from './merge_shared_components'; + +export async function mergeDocuments( + bundledDocuments: BundledDocument[], + blankOasFactory: (oasVersion: string, apiVersion: string) => OpenAPIV3.Document +): Promise> { + const bundledDocumentsByVersion = splitByVersion(bundledDocuments); + const mergedByVersion = new Map(); + + for (const [apiVersion, singleVersionBundledDocuments] of bundledDocumentsByVersion.entries()) { + const oasVersion = extractOasVersion(singleVersionBundledDocuments); + const mergedDocument = blankOasFactory(oasVersion, apiVersion); + + mergedDocument.paths = mergePaths(singleVersionBundledDocuments); + mergedDocument.components = { + // Copy components defined in the blank OpenAPI document + ...mergedDocument.components, + ...mergeSharedComponents(singleVersionBundledDocuments), + }; + + mergedByVersion.set(mergedDocument.info.version, mergedDocument); + } + + return mergedByVersion; +} + +function splitByVersion(bundledDocuments: BundledDocument[]): Map { + const splitBundledDocuments = new Map(); + + for (const bundledDocument of bundledDocuments) { + const documentInfo = bundledDocument.document.info as OpenAPIV3.InfoObject; + + if (!documentInfo.version) { + logger.warning(`OpenAPI version is missing in ${chalk.bold(bundledDocument.absolutePath)}`); + + continue; + } + + const versionBundledDocuments = splitBundledDocuments.get(documentInfo.version); + + if (!versionBundledDocuments) { + splitBundledDocuments.set(documentInfo.version, [bundledDocument]); + } else { + versionBundledDocuments.push(bundledDocument); + } + } + + return splitBundledDocuments; +} + +function extractOasVersion(bundledDocuments: BundledDocument[]): string { + if (bundledDocuments.length === 0) { + throw new Error('Empty bundled document list'); + } + + const firstBundledDocument = bundledDocuments[0]; + + for (let i = 1; i < bundledDocuments.length; ++i) { + if ( + !areOasVersionsEqual( + bundledDocuments[i].document.openapi as string, + firstBundledDocument.document.openapi as string + ) + ) { + throw new Error( + `OpenAPI specs must use the same OpenAPI version, encountered ${chalk.blue( + bundledDocuments[i].document.openapi + )} at ${chalk.bold(bundledDocuments[i].absolutePath)} does not match ${chalk.blue( + firstBundledDocument.document.openapi + )} at ${chalk.bold(firstBundledDocument.absolutePath)}` + ); + } + } + + const version = firstBundledDocument.document.openapi as string; + + // Automatically promote to the recent OAS 3.0 version which is 3.0.3 + // 3.0.3 is the version used in the specification https://swagger.io/specification/v3/ + return version < '3.0.3' ? '3.0.3' : version; +} + +/** + * Tells if versions are equal by comparing only major and minor OAS version parts + */ +function areOasVersionsEqual(versionA: string, versionB: string): boolean { + // versionA.substring(0, 3) results in `3.0` or `3.1` + return versionA.substring(0, 3) === versionB.substring(0, 3); +} diff --git a/packages/kbn-openapi-bundler/src/bundler/merge_documents/merge_paths.ts b/packages/kbn-openapi-bundler/src/bundler/merge_documents/merge_paths.ts new file mode 100644 index 0000000000000..1d541b5bb513e --- /dev/null +++ b/packages/kbn-openapi-bundler/src/bundler/merge_documents/merge_paths.ts @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import chalk from 'chalk'; +import { OpenAPIV3 } from 'openapi-types'; +import { BundledDocument } from '../bundle_document'; + +export function mergePaths(bundledDocuments: BundledDocument[]): OpenAPIV3.PathsObject { + const mergedPaths: Record = {}; + + for (const { absolutePath, document } of bundledDocuments) { + if (!document.paths) { + continue; + } + + const pathsObject = document.paths as Record; + + for (const path of Object.keys(pathsObject)) { + if (!mergedPaths[path]) { + mergedPaths[path] = {}; + } + + const sourcePathItem = pathsObject[path]; + const mergedPathItem = mergedPaths[path]; + + try { + mergeOptionalPrimitiveValue('summary', sourcePathItem, mergedPathItem); + } catch { + throw new Error( + `❌ Unable to bundle ${chalk.bold(absolutePath)} since ${chalk.bold( + `paths.${path}.summary` + )}'s value ${chalk.blue( + sourcePathItem.summary + )} doesn't match to already encountered ${chalk.magenta(mergedPathItem.summary)}.` + ); + } + + try { + mergeOptionalPrimitiveValue('description', sourcePathItem, mergedPathItem); + } catch { + throw new Error( + `❌ Unable to bundle ${chalk.bold(absolutePath)} since ${chalk.bold( + `paths.${path}.description` + )}'s value ${chalk.blue( + sourcePathItem.description + )} doesn't match to already encountered ${chalk.magenta(mergedPathItem.description)}.` + ); + } + + try { + mergeOperations(sourcePathItem, mergedPathItem); + } catch (e) { + throw new Error( + `❌ Unable to bundle ${chalk.bold(absolutePath)} since ${chalk.bold( + `paths.${path}.${e.message}` + )}'s definition is duplicated and differs from previously encountered.` + ); + } + + try { + mergeParameters(sourcePathItem, mergedPathItem); + } catch (e) { + throw new Error( + `❌ Unable to bundle ${chalk.bold(absolutePath)} since ${chalk.bold( + `paths.${path}.parameters.[${e.message}]` + )}'s definition is duplicated and differs from previously encountered.` + ); + } + } + } + + return mergedPaths; +} + +const KNOWN_HTTP_METHODS = [ + OpenAPIV3.HttpMethods.HEAD, + OpenAPIV3.HttpMethods.GET, + OpenAPIV3.HttpMethods.POST, + OpenAPIV3.HttpMethods.PATCH, + OpenAPIV3.HttpMethods.PUT, + OpenAPIV3.HttpMethods.OPTIONS, + OpenAPIV3.HttpMethods.DELETE, + OpenAPIV3.HttpMethods.TRACE, +]; + +function mergeOperations( + sourcePathItem: OpenAPIV3.PathItemObject, + mergedPathItem: OpenAPIV3.PathItemObject +) { + for (const httpMethod of KNOWN_HTTP_METHODS) { + if (!sourcePathItem[httpMethod]) { + continue; + } + + if (mergedPathItem[httpMethod]) { + throw new Error(httpMethod); + } + + mergedPathItem[httpMethod] = sourcePathItem[httpMethod]; + } +} + +function mergeOptionalPrimitiveValue( + fieldName: FieldName, + source: { [field in FieldName]?: unknown }, + merged: { [field in FieldName]?: unknown } +): void { + if (!source[fieldName]) { + return; + } + + if (source[fieldName] && !merged[fieldName]) { + merged[fieldName] = source[fieldName]; + } + + if (source[fieldName] !== merged[fieldName]) { + throw new Error(`${fieldName} merge conflict`); + } +} + +function mergeParameters( + sourcePathItem: OpenAPIV3.PathItemObject, + mergedPathItem: OpenAPIV3.PathItemObject +): void { + if (!sourcePathItem.parameters) { + return; + } + + if (!mergedPathItem.parameters) { + mergedPathItem.parameters = []; + } + + for (const sourceParameter of sourcePathItem.parameters) { + if ('$ref' in sourceParameter) { + const existing = mergedPathItem.parameters.find( + (x) => '$ref' in x && x.$ref === sourceParameter.$ref + ); + + if (existing) { + continue; + } + } else { + const existing = mergedPathItem.parameters.find( + (x) => !('$ref' in x) && x.name === sourceParameter.name && x.in === sourceParameter.in + ); + + if (existing) { + throw new Error(`{ "name": "${sourceParameter.name}", "in": "${sourceParameter.in}" }`); + } + } + + mergedPathItem.parameters.push(sourceParameter); + } +} diff --git a/packages/kbn-openapi-bundler/src/bundler/merge_documents/merge_shared_components.ts b/packages/kbn-openapi-bundler/src/bundler/merge_documents/merge_shared_components.ts new file mode 100644 index 0000000000000..72f55645fa717 --- /dev/null +++ b/packages/kbn-openapi-bundler/src/bundler/merge_documents/merge_shared_components.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { OpenAPIV3 } from 'openapi-types'; +import deepEqual from 'fast-deep-equal'; +import chalk from 'chalk'; +import { insertRefByPointer } from '../../utils/insert_by_json_pointer'; +import { ResolvedRef } from '../types'; +import { BundledDocument } from '../bundle_document'; + +export function mergeSharedComponents( + bundledDocuments: BundledDocument[] +): OpenAPIV3.ComponentsObject { + const componentsMap = new Map(); + const mergedComponents: Record = {}; + + for (const bundledDocument of bundledDocuments) { + mergeRefsToMap(bundledDocument.bundledRefs, componentsMap); + } + + for (const resolvedRef of componentsMap.values()) { + insertRefByPointer(resolvedRef.pointer, resolvedRef.refNode, mergedComponents); + } + + return mergedComponents; +} + +function mergeRefsToMap(bundledRefs: ResolvedRef[], componentsMap: Map): void { + for (const bundledRef of bundledRefs) { + const existingRef = componentsMap.get(bundledRef.pointer); + + if (!existingRef) { + componentsMap.set(bundledRef.pointer, bundledRef); + continue; + } + + if (deepEqual(existingRef.refNode, bundledRef.refNode)) { + continue; + } + + throw new Error( + `❌ Unable to bundle documents due to conflicts in references. Schema ${chalk.yellow( + bundledRef.pointer + )} is defined in ${chalk.blue(existingRef.absolutePath)} and in ${chalk.magenta( + bundledRef.absolutePath + )} but has not matching definitions.` + ); + } +} diff --git a/packages/kbn-openapi-bundler/src/bundler/process_document.test.ts b/packages/kbn-openapi-bundler/src/bundler/process_document.test.ts index d78a4ce515b65..fd6e73d4272c6 100644 --- a/packages/kbn-openapi-bundler/src/bundler/process_document.test.ts +++ b/packages/kbn-openapi-bundler/src/bundler/process_document.test.ts @@ -19,13 +19,13 @@ describe('processDocument', () => { document: {} as Document, }; const calls: string[] = []; - const processor1 = { - leave() { + const processor1: DocumentNodeProcessor = { + onNodeLeave() { calls.push('processor1'); }, }; - const processor2 = { - leave() { + const processor2: DocumentNodeProcessor = { + onNodeLeave() { calls.push('processor2'); }, }; @@ -46,14 +46,17 @@ describe('processDocument', () => { const calls: string[] = []; const refResolver = new RefResolver(); const processor: DocumentNodeProcessor = { - enter(node) { + onNodeEnter(node) { calls.push(`enter - ${(node as NodeWithId).id}`); + }, + shouldRemove(node) { + calls.push(`shouldRemove - ${(node as NodeWithId).id}`); return false; }, - ref(node) { + onRefNodeLeave(node) { calls.push(`ref - ${(node as NodeWithId).id}`); }, - leave(node) { + onNodeLeave(node) { calls.push(`leave - ${(node as NodeWithId).id}`); }, }; @@ -82,8 +85,11 @@ describe('processDocument', () => { ); expect(calls).toEqual([ + 'shouldRemove - root', 'enter - root', + 'shouldRemove - t1', 'enter - t1', + 'shouldRemove - TestRef', 'enter - TestRef', 'leave - TestRef', 'ref - t1', @@ -92,7 +98,7 @@ describe('processDocument', () => { ]); }); - it('removes a node after "enter" callback returned true', async () => { + it('removes a node after "shouldRemove" callback returned true', async () => { const nodeToRemove = { id: 't2', foo: 'bar', @@ -104,7 +110,7 @@ describe('processDocument', () => { t2: nodeToRemove, }; const removeNodeProcessor: DocumentNodeProcessor = { - enter(node) { + shouldRemove(node) { return node === nodeToRemove; }, }; diff --git a/packages/kbn-openapi-bundler/src/bundler/process_document.ts b/packages/kbn-openapi-bundler/src/bundler/process_document.ts index 1efe64b87b4ed..29344fa4aa7c5 100644 --- a/packages/kbn-openapi-bundler/src/bundler/process_document.ts +++ b/packages/kbn-openapi-bundler/src/bundler/process_document.ts @@ -67,11 +67,13 @@ export async function processDocument( traverseItem.visitedDocumentNodes.add(traverseItem.node); - if (shouldSkipNode(traverseItem, processors)) { + if (shouldRemoveSubTree(traverseItem, processors)) { removeNode(traverseItem); continue; } + applyEnterProcessors(traverseItem, processors); + postOrderTraversalStack.push(traverseItem); if (isRefNode(traverseItem.node)) { @@ -144,14 +146,14 @@ export async function processDocument( // If ref has been inlined by one of the processors it's not a ref node anymore // so we can skip the following processors if (isRefNode(traverseItem.node) && traverseItem.resolvedRef) { - processor.ref?.( + processor.onRefNodeLeave?.( traverseItem.node as RefNode, traverseItem.resolvedRef, traverseItem.context ); } - processor.leave?.(traverseItem.node, traverseItem.context); + processor.onNodeLeave?.(traverseItem.node, traverseItem.context); } } } @@ -165,9 +167,28 @@ export function isRefNode(node: DocumentNode): node is { $ref: string } { return isPlainObject(node) && '$ref' in node; } -function shouldSkipNode(traverseItem: TraverseItem, processors: DocumentNodeProcessor[]): boolean { - return processors?.some((p) => - p.enter?.(traverseItem.node, { +function applyEnterProcessors( + traverseItem: TraverseItem, + processors: DocumentNodeProcessor[] +): void { + for (const processor of processors) { + processor.onNodeEnter?.(traverseItem.node, { + ...traverseItem.context, + parentNode: traverseItem.parentNode, + parentKey: traverseItem.parentKey, + }); + } +} + +/** + * Removes a node with its subtree + */ +function shouldRemoveSubTree( + traverseItem: TraverseItem, + processors: DocumentNodeProcessor[] +): boolean { + return processors.some((p) => + p.shouldRemove?.(traverseItem.node, { ...traverseItem.context, parentNode: traverseItem.parentNode, parentKey: traverseItem.parentKey, diff --git a/packages/kbn-openapi-bundler/src/bundler/types.ts b/packages/kbn-openapi-bundler/src/bundler/types.ts index 06aa533c9122a..fa8a7f3c83120 100644 --- a/packages/kbn-openapi-bundler/src/bundler/types.ts +++ b/packages/kbn-openapi-bundler/src/bundler/types.ts @@ -94,41 +94,70 @@ export type TraverseDocumentEntryContext = TraverseDocumentContext & { }; /** - * Entry processor controls when a node should be omitted from the result document. + * Should remove processor controls whether a node and all its descendants + * should be omitted from the further processing and result document. + * + * When result is + * + * - `true` - omit the node + * - `false` - keep the node * - * When result is `true` - omit the node. */ -export type EntryProcessorFn = ( +export type ShouldRemoveNodeProcessorFn = ( node: Readonly, context: TraverseDocumentEntryContext ) => boolean; -export type LeaveProcessorFn = (node: DocumentNode, context: TraverseDocumentContext) => void; +export type OnNodeEntryProcessorFn = ( + node: Readonly, + context: TraverseDocumentEntryContext +) => void; + +export type OnNodeLeaveProcessorFn = (node: DocumentNode, context: TraverseDocumentContext) => void; -export type RefProcessorFn = ( +export type OnRefNodeLeaveProcessorFn = ( node: RefNode, resolvedRef: ResolvedRef, context: TraverseDocumentContext ) => void; /** + * OpenAPI tree is traversed in two phases + * + * 1. Diving from root to leaves. + * Allows to analyze unprocessed nodes and calculate any metrics if necessary. + * + * 2. Post order traversal from leaves to root. + * Mostly to transform the OpenAPI document. + * * Document or document node processor gives flexibility in modifying OpenAPI specs and/or collect some metrics. - * For convenience it defined handlers invoked upon action or specific node type. + * For convenience there are following node processors supported + * + * 1st phase + * + * - `onNodeEnter` - Callback function is invoked at the first phase (diving from root to leaves) while + * traversing the document. It can be considered in a similar way events dive in DOM during + * capture phase. In the other words it means entering a subtree. It allows to analyze + * unprocessed nodes. * - * Currently the following node types supported + * - `shouldRemove` - Callback function is invoked at the first phase (diving from root to leaves) while + * traversing the document. It controls whether the node will be excluded from further processing + * and the result document eventually. Returning `true` excluded the node while returning `false` + * passes the node untouched. * - * - ref - Callback function is invoked upon leaving ref node (a node having `$ref` key) + * 2nd phase * - * and the following actions + * - `onNodeLeave` - Callback function is invoked upon leaving any type of node. It give an opportunity to + * modify the document like inline references or remove unwanted properties. It can be considered + * in a similar way event bubble in DOM during bubble phase. In the other words it means leaving + * a subtree. * - * - enter - Callback function is invoked upon entering any type of node element including ref nodes. It doesn't allow - * to modify node's content but provides an ability to remove the element by returning `true`. + * - `onRefNodeLeave` - Callback function is invoked upon leaving a reference node (a node having `$ref` key) * - * - leave - Callback function is invoked upon leaving any type of node. It give an opportunity to modify the document like - * dereference refs or remove unwanted properties. */ export interface DocumentNodeProcessor { - enter?: EntryProcessorFn; - leave?: LeaveProcessorFn; - ref?: RefProcessorFn; + shouldRemove?: ShouldRemoveNodeProcessorFn; + onNodeEnter?: OnNodeEntryProcessorFn; + onNodeLeave?: OnNodeLeaveProcessorFn; + onRefNodeLeave?: OnRefNodeLeaveProcessorFn; } diff --git a/packages/kbn-openapi-bundler/src/openapi_bundler.test.ts b/packages/kbn-openapi-bundler/src/openapi_bundler.test.ts deleted file mode 100644 index eaed80727dee8..0000000000000 --- a/packages/kbn-openapi-bundler/src/openapi_bundler.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { existsSync, rmSync } from 'fs'; -import { basename, join } from 'path'; -import { bundle } from './openapi_bundler'; -import { readYamlDocument } from './utils/read_yaml_document'; - -const rootPath = join(__dirname, '__test__'); -const targetAbsoluteFilePath = join(rootPath, 'bundled.yaml'); - -describe('OpenAPI Bundler', () => { - afterEach(() => { - removeTargetFile(); - }); - - it('bundles two simple specs', async () => { - await bundleFolder('two_simple_specs'); - await expectBundleToMatchFile('two_simple_specs', 'expected.yaml'); - }); - - it('bundles one file with a local reference', async () => { - await bundleFolder('spec_with_local_ref'); - await expectBundleToMatchFile('spec_with_local_ref', 'expected.yaml'); - }); - - it('bundles one file with an external reference', async () => { - await bundleFolder('spec_with_external_ref'); - await expectBundleToMatchFile('spec_with_external_ref', 'expected.yaml'); - }); - - it('bundles files with external references', async () => { - await bundleFolder('two_specs_with_external_ref'); - await expectBundleToMatchFile('two_specs_with_external_ref', 'expected.yaml'); - }); - - // Fails because `writeYamlDocument()` has `noRefs: true` setting - // it('bundles recursive spec', async () => { - // await bundleFolder('recursive_spec'); - // await expectBundleToMatchFile('recursive_spec', 'expected.yaml'); - // }); - - it('bundles specs with recursive references', async () => { - await bundleFolder('recursive_ref_specs'); - await expectBundleToMatchFile('recursive_ref_specs', 'expected.yaml'); - }); - - it('bundles spec with a self-recursive reference', async () => { - await bundleFolder('self_recursive_ref'); - await expectBundleToMatchFile('self_recursive_ref', 'expected.yaml'); - }); - - it('bundles one endpoint with different versions', async () => { - await bundleFolder('different_endpoint_versions'); - await expectBundleToMatchFile('different_endpoint_versions', 'expected.yaml'); - }); - - it('bundles spec with different OpenAPI versions', async () => { - await bundleFolder('different_openapi_versions'); - await expectBundleToMatchFile('different_openapi_versions', 'expected.yaml'); - }); - - it('bundles conflicting but equal references', async () => { - await bundleFolder('conflicting_but_equal_refs_in_different_specs'); - await expectBundleToMatchFile('conflicting_but_equal_refs_in_different_specs', 'expected.yaml'); - }); - - it('fails to bundle conflicting references encountered in separate specs', async () => { - await expectBundlingError( - 'conflicting_refs_in_different_specs', - /\/components\/schemas\/ConflictTestSchema/ - ); - }); - - describe('x-modify', () => { - it('makes properties in an object node partial', async () => { - await bundleFolder('modify_partial_node'); - await expectBundleToMatchFile('modify_partial_node', 'expected.yaml'); - }); - - it('makes properties in a referenced object node partial', async () => { - await bundleFolder('modify_partial_ref'); - await expectBundleToMatchFile('modify_partial_ref', 'expected.yaml'); - }); - - it('makes properties in an object node required', async () => { - await bundleFolder('modify_required_node'); - await expectBundleToMatchFile('modify_required_node', 'expected.yaml'); - }); - - it('makes properties in a referenced object node required', async () => { - await bundleFolder('modify_required_ref'); - await expectBundleToMatchFile('modify_required_ref', 'expected.yaml'); - }); - }); - - describe('x-inline', () => { - it('inlines a reference', async () => { - await bundleFolder('inline_ref'); - await expectBundleToMatchFile('inline_ref', 'expected.yaml'); - }); - }); - - describe('skip internal', () => { - it('skips nodes with x-internal property', async () => { - await bundleFolder('skip_internal'); - await expectBundleToMatchFile('skip_internal', 'expected.yaml'); - }); - - it('skips endpoints starting with /internal', async () => { - await bundleFolder('skip_internal_endpoint'); - await expectBundleToMatchFile('skip_internal_endpoint', 'expected.yaml'); - }); - }); -}); - -async function bundleFolder(folderName: string): Promise { - await expect( - bundle({ - rootDir: join(rootPath, folderName), - sourceGlob: '*.schema.yaml', - outputFilePath: join('..', basename(targetAbsoluteFilePath)), - }) - ).resolves.toBeUndefined(); -} - -async function expectBundlingError( - folderName: string, - error: string | RegExp | jest.Constructable | Error | undefined -): Promise { - return await expect( - bundle({ - rootDir: join(rootPath, folderName), - sourceGlob: '*.schema.yaml', - outputFilePath: join('..', basename(targetAbsoluteFilePath)), - }) - ).rejects.toThrowError(error); -} - -async function expectBundleToMatchFile( - folderName: string, - expectedFileName: string -): Promise { - expect(existsSync(targetAbsoluteFilePath)).toBeTruthy(); - - const bundledSpec = await readYamlDocument(targetAbsoluteFilePath); - const expectedAbsoluteFilePath = join(rootPath, folderName, expectedFileName); - const expectedSpec = await readYamlDocument(expectedAbsoluteFilePath); - - expect(bundledSpec).toEqual(expectedSpec); -} - -function removeTargetFile(): void { - if (existsSync(targetAbsoluteFilePath)) { - rmSync(targetAbsoluteFilePath, { force: true }); - } -} diff --git a/packages/kbn-openapi-bundler/src/openapi_bundler.ts b/packages/kbn-openapi-bundler/src/openapi_bundler.ts index 451b0ff700bae..554758b622995 100644 --- a/packages/kbn-openapi-bundler/src/openapi_bundler.ts +++ b/packages/kbn-openapi-bundler/src/openapi_bundler.ts @@ -7,43 +7,75 @@ */ import chalk from 'chalk'; +import { isUndefined, omitBy } from 'lodash'; +import { OpenAPIV3 } from 'openapi-types'; import globby from 'globby'; -import { basename, dirname, join, resolve } from 'path'; +import { basename, dirname, resolve } from 'path'; import { BundledDocument, bundleDocument, SkipException } from './bundler/bundle_document'; import { mergeDocuments } from './bundler/merge_documents'; import { removeFilesByGlob } from './utils/remove_files_by_glob'; import { logger } from './logger'; import { writeYamlDocument } from './utils/write_yaml_document'; +import { createBlankOpenApiDocument } from './bundler/merge_documents/create_blank_oas_document'; export interface BundlerConfig { - rootDir: string; sourceGlob: string; outputFilePath: string; + specInfo?: Omit, 'version'>; } -export const bundle = async (config: BundlerConfig) => { - const { - rootDir, - sourceGlob, - outputFilePath: relativeOutputFilePath = 'target/openapi/bundled.schema.yaml', - } = config; - +export const bundle = async ({ + sourceGlob, + outputFilePath = 'bundled-{version}.schema.yaml', + specInfo, +}: BundlerConfig) => { logger.debug(chalk.bold(`Bundling API route schemas`)); - logger.debug(chalk.bold(`Working directory: ${chalk.underline(rootDir)}`)); - logger.debug(`👀 Searching for source files`); + logger.debug(`👀 Searching for source files in ${chalk.underline(sourceGlob)}`); - const outputFilePath = join(rootDir, relativeOutputFilePath); - const sourceFilesGlob = resolve(rootDir, sourceGlob); + const sourceFilesGlob = resolve(sourceGlob); const schemaFilePaths = await globby([sourceFilesGlob]); logger.info(`🕵️‍♀️ Found ${schemaFilePaths.length} schemas`); logSchemas(schemaFilePaths); logger.info(`🧹 Cleaning up any previously generated artifacts`); - await removeFilesByGlob(dirname(outputFilePath), basename(outputFilePath)); + await removeFilesByGlob( + dirname(outputFilePath), + basename(outputFilePath.replace('{version}', '*')) + ); logger.debug(`Processing schemas...`); + const resolvedDocuments = await resolveDocuments(schemaFilePaths); + + logger.success(`Processed ${resolvedDocuments.length} schemas`); + + const blankOasFactory = (oasVersion: string, apiVersion: string) => + createBlankOpenApiDocument(oasVersion, { + version: apiVersion, + title: specInfo?.title ?? 'Bundled OpenAPI specs', + ...omitBy( + { + description: specInfo?.description, + termsOfService: specInfo?.termsOfService, + contact: specInfo?.contact, + license: specInfo?.license, + }, + isUndefined + ), + }); + const resultDocumentsMap = await mergeDocuments(resolvedDocuments, blankOasFactory); + + await writeDocuments(resultDocumentsMap, outputFilePath); +}; + +function logSchemas(schemaFilePaths: string[]): void { + for (const filePath of schemaFilePaths) { + logger.debug(`Found OpenAPI spec ${chalk.bold(filePath)}`); + } +} + +async function resolveDocuments(schemaFilePaths: string[]): Promise { const resolvedDocuments = await Promise.all( schemaFilePaths.map(async (schemaFilePath) => { try { @@ -62,26 +94,9 @@ export const bundle = async (config: BundlerConfig) => { } }) ); - const processedDocuments = filterOutSkippedDocuments(resolvedDocuments); - logger.success(`Processed ${processedDocuments.length} schemas`); - - const resultDocument = await mergeDocuments(processedDocuments); - - try { - await writeYamlDocument(outputFilePath, resultDocument); - - logger.success(`📖 Wrote all bundled OpenAPI specs to ${chalk.bold(outputFilePath)}`); - } catch (e) { - logger.error(`Unable to save bundled document to ${chalk.bold(outputFilePath)}: ${e.message}`); - } -}; - -function logSchemas(schemaFilePaths: string[]): void { - for (const filePath of schemaFilePaths) { - logger.debug(`Found OpenAPI spec ${chalk.bold(filePath)}`); - } + return processedDocuments; } function filterOutSkippedDocuments( @@ -99,3 +114,34 @@ function filterOutSkippedDocuments( return processedDocuments; } + +async function writeDocuments( + resultDocumentsMap: Map, + outputFilePath: string +): Promise { + for (const [version, document] of resultDocumentsMap.entries()) { + const versionedOutputFilePath = getVersionedOutputFilePath(outputFilePath, version); + + try { + await writeYamlDocument(versionedOutputFilePath, document); + + logger.success(`📖 Wrote bundled OpenAPI specs to ${chalk.bold(versionedOutputFilePath)}`); + } catch (e) { + logger.error( + `Unable to save bundled document to ${chalk.bold(versionedOutputFilePath)}: ${e.message}` + ); + } + } +} + +function getVersionedOutputFilePath(outputFilePath: string, version: string): string { + const hasVersionPlaceholder = outputFilePath.indexOf('{version}') > -1; + + if (hasVersionPlaceholder) { + return outputFilePath.replace('{version}', version); + } + + const filename = basename(outputFilePath); + + return outputFilePath.replace(filename, `${version}-${filename}`); +} diff --git a/packages/kbn-openapi-bundler/src/utils/insert_by_json_pointer.ts b/packages/kbn-openapi-bundler/src/utils/insert_by_json_pointer.ts index 8538102305edc..161f548ad2cf9 100644 --- a/packages/kbn-openapi-bundler/src/utils/insert_by_json_pointer.ts +++ b/packages/kbn-openapi-bundler/src/utils/insert_by_json_pointer.ts @@ -10,16 +10,23 @@ * Inserts `data` into the location specified by pointer in the `document`. * * @param pointer [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) - * @param data An object to insert - * @param document A document to insert to + * @param component Component data to insert + * @param componentsObject Components object to insert to */ export function insertRefByPointer( pointer: string, - data: unknown, - document: Record + component: unknown, + componentsObject: Record ): void { + if (!pointer.startsWith('/components')) { + throw new Error( + `insertRefByPointer expected a pointer starting with "/components" but got ${pointer}` + ); + } + + // splitting '/components' by '/' gives ['', 'components'] which should be skipped const segments = pointer.split('/').slice(2); - let target = document; + let target = componentsObject; while (segments.length > 0) { const segment = segments.shift() as string; @@ -31,5 +38,5 @@ export function insertRefByPointer( target = target[segment] as Record; } - Object.assign(target, data); + Object.assign(target, component); } diff --git a/packages/kbn-openapi-bundler/src/utils/write_yaml_document.ts b/packages/kbn-openapi-bundler/src/utils/write_yaml_document.ts index bdcd783e1a214..45ad2d5987bad 100644 --- a/packages/kbn-openapi-bundler/src/utils/write_yaml_document.ts +++ b/packages/kbn-openapi-bundler/src/utils/write_yaml_document.ts @@ -12,7 +12,7 @@ import { dirname } from 'path'; export async function writeYamlDocument(filePath: string, document: unknown): Promise { try { - const yaml = dump(document, { noRefs: true }); + const yaml = stringifyToYaml(document); await fs.mkdir(dirname(filePath), { recursive: true }); await fs.writeFile(filePath, yaml); @@ -20,3 +20,21 @@ export async function writeYamlDocument(filePath: string, document: unknown): Pr throw new Error(`Unable to write bundled yaml: ${e.message}`, { cause: e }); } } + +function stringifyToYaml(document: unknown): string { + try { + // Disable YAML Anchors https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases + // It makes YAML much more human readable + return dump(document, { noRefs: true }); + } catch (e) { + // RangeError might happened because of stack overflow + // due to circular references in the document + // since YAML Anchors are disabled + if (e instanceof RangeError) { + // Try to stringify with YAML Anchors enabled + return dump(document, { noRefs: false }); + } + + throw e; + } +} diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_settings_route.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_settings_route.schema.yaml index aad2d49032856..cb67a3686822b 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_settings_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_settings_route.schema.yaml @@ -13,6 +13,7 @@ servers: paths: /engine/settings: + x-internal: true get: operationId: RiskEngineSettingsGet summary: Get the settings of the Risk Engine @@ -23,11 +24,11 @@ paths: application/json: schema: $ref: '#/components/schemas/RiskEngineSettingsResponse' - + components: schemas: RiskEngineSettingsResponse: type: object properties: range: - $ref: '../common/common.schema.yaml#/components/schemas/DateRange' \ No newline at end of file + $ref: '../common/common.schema.yaml#/components/schemas/DateRange' diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.schema.yaml index de5f01f850187..f6a52fbceb232 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.schema.yaml @@ -15,6 +15,7 @@ servers: paths: /api/risk_scores/calculation/entity: + x-internal: true post: summary: Trigger calculation of Risk Scores for an entity description: Calculates and persists Risk Scores for an entity, returning the calculated risk score. @@ -44,7 +45,7 @@ components: - identifier_type properties: identifier: - description: Used to identify the entity. + description: Used to identify the entity. type: string example: 'my.host' identifier_type: @@ -52,11 +53,11 @@ components: $ref: './common.schema.yaml#/components/schemas/IdentifierType' RiskScoresEntityCalculationResponse: - type: object - required: - - success - properties: - success: - type: boolean - score: - $ref: './common.schema.yaml#/components/schemas/RiskScore' + type: object + required: + - success + properties: + success: + type: boolean + score: + $ref: './common.schema.yaml#/components/schemas/RiskScore' diff --git a/x-pack/plugins/security_solution/scripts/openapi/bundle.js b/x-pack/plugins/security_solution/scripts/openapi/bundle.js index 6cfa1507ea9ee..82280d0ef0ebf 100644 --- a/x-pack/plugins/security_solution/scripts/openapi/bundle.js +++ b/x-pack/plugins/security_solution/scripts/openapi/bundle.js @@ -7,12 +7,14 @@ require('../../../../../src/setup_node_env'); const { bundle } = require('@kbn/openapi-bundler'); -const { resolve } = require('path'); +const { join, resolve } = require('path'); const SECURITY_SOLUTION_ROOT = resolve(__dirname, '../..'); bundle({ - rootDir: SECURITY_SOLUTION_ROOT, - sourceGlob: './common/api/**/*.schema.yaml', - outputFilePath: './target/openapi/security_solution.bundled.schema.yaml', + sourceGlob: join(SECURITY_SOLUTION_ROOT, 'common/api/**/*.schema.yaml'), + outputFilePath: join( + SECURITY_SOLUTION_ROOT, + 'target/openapi/security_solution-{version}.bundled.schema.yaml' + ), });