Skip to content

Commit

Permalink
[Security Solution] Add tags by OpenAPI bundler (elastic#189621)
Browse files Browse the repository at this point in the history
**Resolves:** elastic#183375

## Summary

This PR implements functionality assigning a provided tag to OpenAPI `Operation object`s in the result bundle. Specified tag is also added as the only root level OpenAPI tag. This approach allows to produce domain bundles having a single tag assigned. At the next step domain bundles are merged together into single Kibana bundle where tags will allow to properly display grouping at Bump.sh (API reference documentation platform).

## Details

Bump.sh (our new API reference documentation platform) uses OpenAPI tags for grouping API endpoints. It supports only one tag per endpoint.

This PR facilitates preparation of Kibana OpenAPI bundle to be uploaded to Bump.sh by implementing functionality assigning a provided tag to OpenAPI `Operation object`s in the result domain bundles. It's implemented by providing an optional configuration option `assignTag` whose format is OpenAPI Tag Object. When `assignTag` isn't specified the bundler merges existing tags.

## Example

Consider the following bundling configuration

```js
const { bundle } = require('@kbn/openapi-bundler');

bundle({
  // ...
  options: {
    assignTag: {
      name: 'Some Domain API tag name',
      description: 'Some Domain API description',
      externalDocs: {
        url: 'https://some-external-documentation-url',
        description: 'External documentation description',
    }
  },
});
```

and source OpenAPI specs

**spec1.schema.yaml**
```yaml
openapi: 3.0.3
info:
  title: Spec1
  version: '2023-10-31'
paths:
  /api/some_api:
    get:
      tags: ['Some local tag']
      responses:
        200:
          content:
            'application/json':
              schema:
                type: string
```

**spec2.schema.yaml**
```yaml
openapi: 3.0.3
info:
  title: Spec2
  version: '2023-10-31'
paths:
  /api/some_api:
    post:
      tags: ['Some global tag']
      responses:
        200:
          content:
            'application/json':
              schema:
                type: string
tags:
  - name: Some global tag
```

**spec2.schema.yaml**
```yaml
openapi: 3.0.3
info:
  title: Spec3
  version: '2023-10-31'
paths:
  /api/another_api:
    get:
      responses:
        200:
          content:
            'application/json':
              schema:
                type: string
```

After bundling above OpenAPI specs with the provided bundling script we'll get the following

**domain-bundle.schema.yaml**
```yaml
openapi: 3.0.3
info:
  title: Bundled document
  version: '2023-10-31'
paths:
  /api/some_api:
    get:
      tags: ['Some Domain API tag name']
      responses:
        200:
          content:
            'application/json':
              schema:
                type: string
    post:
      tags: ['Some Domain API tag name']
      responses:
        200:
          content:
            'application/json':
              schema:
                type: string
  /api/another_api:
    get:
      tags: ['Some Domain API tag name']
      responses:
        200:
          content:
            'application/json':
              schema:
                type: string
tags:
  - name: Some Domain API tag name
    description: Some Domain API description
    externalDocs:
      url: 'https://some-external-documentation-url'
      description: External documentation description
```
  • Loading branch information
maximpn authored Aug 2, 2024
1 parent cf1222f commit 845dd1f
Show file tree
Hide file tree
Showing 75 changed files with 950 additions and 184 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ export async function mergeDocuments(
mergedDocument.security = mergeSecurityRequirements(documentsToMerge);
}

mergedDocument.tags = mergeTags(documentsToMerge);
const mergedTags = [...(options.addTags ?? []), ...(mergeTags(documentsToMerge) ?? [])];

if (mergedTags.length) {
mergedDocument.tags = mergedTags;
}

mergedByVersion.set(mergedDocument.info.version, mergedDocument);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,17 @@ export function mergeOperations(
continue;
}

// Adding tags before merging helps to reuse already existing functionality
// without changes. It imitates a case when such tags already existed in source operations.
const extendedTags = [
...(options.addTags?.map((t) => t.name) ?? []),
...(sourceOperation.tags ?? []),
];
const normalizedSourceOperation = {
...sourceOperation,
...(options.skipServers ? { servers: undefined } : { servers: sourceOperation.servers }),
...(options.skipSecurity ? { security: undefined } : { security: sourceOperation.security }),
...(extendedTags.length > 0 ? { tags: extendedTags } : {}),
};

if (!mergedOperation || deepEqual(normalizedSourceOperation, mergedOperation)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
* Side Public License, v 1.
*/

import { OpenAPIV3 } from 'openapi-types';

export interface MergeOptions {
skipServers: boolean;
skipSecurity: boolean;
addTags?: OpenAPIV3.TagObject[];
}
5 changes: 3 additions & 2 deletions packages/kbn-openapi-bundler/src/openapi_bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ interface BundleOptions {
*/
prototypeDocument?: PrototypeDocument | string;
/**
* When specified the produced bundle will contain only
* When `includeLabels` are specified the produced bundle will contain only
* operations objects with matching labels
*/
includeLabels?: string[];
}

export const bundle = async ({
sourceGlob,
outputFilePath = 'bundled-{version}.schema.yaml',
outputFilePath = 'bundled_{version}.schema.yaml',
options,
}: BundlerConfig) => {
const prototypeDocument = options?.prototypeDocument
Expand Down Expand Up @@ -82,6 +82,7 @@ export const bundle = async ({
splitDocumentsByVersion: true,
skipServers: Boolean(prototypeDocument?.servers),
skipSecurity: Boolean(prototypeDocument?.security),
addTags: prototypeDocument?.tags,
});

await writeDocuments(resultDocumentsMap, outputFilePath);
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-openapi-bundler/src/openapi_merger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const merge = async ({
splitDocumentsByVersion: false,
skipServers: Boolean(prototypeDocument?.servers),
skipSecurity: Boolean(prototypeDocument?.security),
addTags: prototypeDocument?.tags,
});
// Only one document is expected when `splitDocumentsByVersion` is set to `false`
const mergedDocument = Array.from(resultDocumentsMap.values())[0];
Expand Down
34 changes: 25 additions & 9 deletions packages/kbn-openapi-bundler/src/prototype_document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,37 @@
import { OpenAPIV3 } from 'openapi-types';

/**
* `PrototypeDocument` is used as a prototype for the result file. In the other words
* it provides a way to specify the following properties
*
* - `info` info object
* - `servers` servers used to replace `servers` in the source OpenAPI specs
* - `security` security requirements used to replace `security` in the source OpenAPI specs
* It must be specified together with `components.securitySchemes`.
*
* All the other properties will be ignored.
* `PrototypeDocument` is used as a prototype for the result file.
* Only specified properties are used. All the other properties will be ignored.
*/
export interface PrototypeDocument {
/**
* Defines OpenAPI Info Object to be used in the result document.
* `bundle()` utility doesn't use `info.version`.
*/
info?: Partial<OpenAPIV3.InfoObject>;
/**
* Defines `servers` to be used in the result document. When `servers`
* are set existing source documents `servers` aren't included into
* the result document.
*/
servers?: OpenAPIV3.ServerObject[];
/**
* Defines security requirements to be used in the result document. It must
* be used together with `components.securitySchemes` When `security`
* is set existing source documents `security` isn't included into
* the result document.
*/
security?: OpenAPIV3.SecurityRequirementObject[];
components?: {
/**
* Defines security schemes for security requirements.
*/
securitySchemes: Record<string, OpenAPIV3.SecuritySchemeObject>;
};
/**
* Defines tags to be added to the result document. Tags are added to
* root level tags and prepended to operation object tags.
*/
tags?: OpenAPIV3.TagObject[];
}
28 changes: 28 additions & 0 deletions packages/kbn-openapi-bundler/src/validate_prototype_document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,34 @@ export async function validatePrototypeDocument(
? await readDocument(prototypeDocumentOrString)
: prototypeDocumentOrString;

if (prototypeDocument.servers && !Array.isArray(prototypeDocument.servers)) {
throw new Error(`Prototype document's ${chalk.bold('servers')} must be an array`);
}

if (prototypeDocument.servers && prototypeDocument.servers.length === 0) {
throw new Error(
`Prototype document's ${chalk.bold('servers')} should have as minimum one entry`
);
}

if (prototypeDocument.security && !Array.isArray(prototypeDocument.security)) {
throw new Error(`Prototype document's ${chalk.bold('security')} must be an array`);
}

if (prototypeDocument.security && prototypeDocument.security.length === 0) {
throw new Error(
`Prototype document's ${chalk.bold('security')} should have as minimum one entry`
);
}

if (prototypeDocument.tags && !Array.isArray(prototypeDocument.tags)) {
throw new Error(`Prototype document's ${chalk.bold('tags')} must be an array`);
}

if (prototypeDocument.tags && prototypeDocument.tags.length === 0) {
throw new Error(`Prototype document's ${chalk.bold('tags')} should have as minimum one entry`);
}

if (prototypeDocument.security && !prototypeDocument.components?.securitySchemes) {
throw new Error(
`Prototype document must contain ${chalk.bold(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* 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 { createOASDocument } from '../../create_oas_document';
import { bundleSpecs } from '../bundle_specs';

describe('OpenAPI Bundler - assign a tag', () => {
it('adds tags when nothing is set', async () => {
const spec1 = createOASDocument({
paths: {
'/api/some_api': {
get: {
responses: {
'200': {
description: 'Successful response',
content: {
'application/json': {
schema: {
type: 'string',
},
},
},
},
},
},
},
},
});
const spec2 = createOASDocument({
paths: {
'/api/another_api': {
get: {
responses: {
'200': {
description: 'Successful response',
content: {
'application/json': {
schema: {
type: 'string',
},
},
},
},
},
},
},
},
});

const [bundledSpec] = Object.values(
await bundleSpecs(
{
1: spec1,
2: spec2,
},
{
prototypeDocument: {
tags: [
{
name: 'Some Tag',
description: 'Some tag description',
},
{
name: 'Another Tag',
description: 'Another tag description',
},
],
},
}
)
);

expect(bundledSpec.paths['/api/some_api']?.get?.tags).toEqual(['Some Tag', 'Another Tag']);
expect(bundledSpec.paths['/api/another_api']?.get?.tags).toEqual(['Some Tag', 'Another Tag']);
expect(bundledSpec.tags).toEqual([
{
name: 'Some Tag',
description: 'Some tag description',
},
{
name: 'Another Tag',
description: 'Another tag description',
},
]);
});

it('adds tags to existing tags', async () => {
const spec1 = createOASDocument({
paths: {
'/api/some_api': {
get: {
tags: ['Local tag'],
responses: {
'200': {
description: 'Successful response',
content: {
'application/json': {
schema: {
type: 'string',
},
},
},
},
},
},
},
},
});
const spec2 = createOASDocument({
paths: {
'/api/another_api': {
get: {
tags: ['Global tag'],
responses: {
'200': {
description: 'Successful response',
content: {
'application/json': {
schema: {
type: 'string',
},
},
},
},
},
},
},
},
tags: [{ name: 'Global tag', description: 'Global tag description' }],
});

const [bundledSpec] = Object.values(
await bundleSpecs(
{
1: spec1,
2: spec2,
},
{
prototypeDocument: {
tags: [
{
name: 'Some Tag',
description: 'Some tag description',
},
{
name: 'Another Tag',
description: 'Another tag description',
},
],
},
}
)
);

expect(bundledSpec.paths['/api/some_api']?.get?.tags).toEqual([
'Some Tag',
'Another Tag',
'Local tag',
]);
expect(bundledSpec.paths['/api/another_api']?.get?.tags).toEqual([
'Some Tag',
'Another Tag',
'Global tag',
]);
expect(bundledSpec.tags).toEqual([
{
name: 'Some Tag',
description: 'Some tag description',
},
{
name: 'Another Tag',
description: 'Another tag description',
},
{ name: 'Global tag', description: 'Global tag description' },
]);
});
});
Loading

0 comments on commit 845dd1f

Please sign in to comment.