[Security Solution] Produce stable OAS bundle (#183053)

**Resolves:** https://github.com/elastic/kibana/issues/183051

## Summary

This PR add normalization to the OAS bundler produced by `@kbn/openapi-bundler` to get a stable OAS bundle. Normalization is achieved by sorting keys during serialization to YAML.
This commit is contained in:
Maxim Palenov 2024-05-10 16:00:08 +02:00 committed by GitHub
parent 04417da086
commit 382f4ae808
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 140 additions and 8 deletions

View file

@ -267,7 +267,7 @@ describe('OpenAPI Bundler - bundle references', () => {
},
});
const { '2023-10-31.yaml': bundledSpec } = await bundleSpecs({ '1': spec1, '2': spec2 });
const [bundledSpec] = Object.values(await bundleSpecs({ '1': spec1, '2': spec2 }));
expect(bundledSpec.paths['/api/some_api']).toEqual({
get: spec1.paths['/api/some_api']!.get,

View file

@ -49,8 +49,7 @@ describe('OpenAPI Bundler - circular specs', () => {
);
expect(dump(bundledSpec.paths['/api/some_api']!.get!.responses['200'])).toMatchInlineSnapshot(`
"description: Successful response
content:
"content:
application/json:
schema: &ref_0
type: object
@ -58,6 +57,7 @@ content:
fieldA:
type: integer
fieldB: *ref_0
description: Successful response
"
`);
});

View file

@ -77,10 +77,10 @@ describe('OpenAPI Bundler - different API versions', () => {
});
expect(bundledSpecs).toEqual({
'2023-10-31.yaml': expect.objectContaining({
'2023_10_31.yaml': expect.objectContaining({
paths: spec1.paths,
}),
'2023-11-11.yaml': expect.objectContaining({
'2023_11_11.yaml': expect.objectContaining({
paths: spec2.paths,
}),
});

View file

@ -0,0 +1,102 @@
/*
* 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 - produce stable bundle', () => {
it('produces stable bundle (keys are sorted)', async () => {
const response: OpenAPIV3.ResponseObject = {
description: 'Successful response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
fieldA: {
$ref: './common.schema.yaml#/components/schemas/SchemaB',
},
fieldB: {
$ref: './common.schema.yaml#/components/schemas/SchemaA',
},
},
},
},
},
};
const spec1 = createOASDocument({
paths: {
'/api/some_api': {
post: {
responses: {
'200': response,
},
},
get: {
responses: {
'200': response,
},
},
put: {
responses: {
'200': response,
},
},
},
},
});
const spec2 = createOASDocument({
paths: {
'/api/another_api': {
get: {
responses: {
'200': response,
},
},
put: {
responses: {
'200': response,
},
},
patch: {
responses: {
'200': response,
},
},
},
},
});
const commonSpec = createOASDocument({
components: {
schemas: {
SchemaB: {
type: 'string',
},
SchemaA: {
type: 'number',
},
},
},
});
const [bundledSpec] = Object.values(
await bundleSpecs({
1: spec1,
2: spec2,
common: commonSpec,
})
);
expect(Object.keys(bundledSpec.paths)).toEqual(['/api/another_api', '/api/some_api']);
expect(Object.keys(bundledSpec.paths['/api/another_api']!)).toEqual(['get', 'patch', 'put']);
expect(Object.keys(bundledSpec.paths['/api/some_api']!)).toEqual(['get', 'post', 'put']);
expect(Object.keys(bundledSpec.components!.schemas!)).toEqual(['SchemaA', 'SchemaB']);
});
});

View file

@ -136,9 +136,10 @@ async function writeDocuments(
function getVersionedOutputFilePath(outputFilePath: string, version: string): string {
const hasVersionPlaceholder = outputFilePath.indexOf('{version}') > -1;
const snakeCasedVersion = version.replaceAll(/[^\w\d]+/g, '_');
if (hasVersionPlaceholder) {
return outputFilePath.replace('{version}', version);
return outputFilePath.replace('{version}', snakeCasedVersion);
}
const filename = basename(outputFilePath);

View file

@ -25,16 +25,45 @@ 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 });
return dump(document, {
noRefs: true,
sortKeys: sortYamlKeys,
});
} 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 });
return dump(document, { noRefs: false, sortKeys: sortYamlKeys });
}
throw e;
}
}
function sortYamlKeys(a: string, b: string): number {
if (a in FIELDS_ORDER && b in FIELDS_ORDER) {
return FIELDS_ORDER[a as CustomOrderedField] - FIELDS_ORDER[b as CustomOrderedField];
}
return a.localeCompare(b);
}
const FIELDS_ORDER = {
// root level fields
openapi: 1,
info: 2,
servers: 3,
paths: 4,
components: 5,
security: 6,
tags: 7,
externalDocs: 8,
// object schema fields
type: 9,
properties: 10,
required: 11,
} as const;
type CustomOrderedField = keyof typeof FIELDS_ORDER;