fix: remove unused components transitively (#1472)

This commit is contained in:
Felix Boehm
2024-04-03 08:30:43 +01:00
committed by GitHub
parent b76716134a
commit db21d90a9f
5 changed files with 312 additions and 45 deletions

View File

@@ -0,0 +1,5 @@
---
"@redocly/openapi-core": patch
---
Process remove-unused-components rule transitively; components are now removed if they were previously referenced by a removed component.

View File

@@ -1,5 +1,5 @@
import { outdent } from 'outdent'; import { outdent } from 'outdent';
import { parseYamlToDocument, replaceSourceWithRef, makeConfig } from '../../../../__tests__/utils'; import { parseYamlToDocument, makeConfig } from '../../../../__tests__/utils';
import { bundleDocument } from '../../../bundle'; import { bundleDocument } from '../../../bundle';
import { BaseResolver } from '../../../resolve'; import { BaseResolver } from '../../../resolve';
@@ -152,4 +152,77 @@ describe('oas2 remove-unused-components', () => {
}, },
}); });
}); });
it('should remove transitively unused components', async () => {
const document = parseYamlToDocument(
outdent`
swagger: '2.0'
paths:
/pets:
get:
produces:
- application/json
parameters: []
responses:
'200':
schema:
$ref: '#/definitions/Used'
operationId: listPets
summary: List all pets
definitions:
Unused:
enum:
- 1
- 2
type: integer
UnusedTransitive:
type: object
properties:
link:
$ref: '#/definitions/Unused'
Used:
properties:
link:
type: string
type: object
`,
'foobar.yaml'
);
const results = await bundleDocument({
externalRefResolver: new BaseResolver(),
document,
config: await makeConfig({}),
removeUnusedComponents: true,
});
expect(results.bundle.parsed).toEqual({
swagger: '2.0',
definitions: {
Used: {
properties: {
link: { type: 'string' },
},
type: 'object',
},
},
paths: {
'/pets': {
get: {
produces: ['application/json'],
parameters: [],
summary: 'List all pets',
operationId: 'listPets',
responses: {
'200': {
schema: {
$ref: '#/definitions/Used',
},
},
},
},
},
},
});
});
}); });

View File

@@ -2,12 +2,12 @@ import { Location } from '../../ref-utils';
import { isEmptyObject } from '../../utils'; import { isEmptyObject } from '../../utils';
import type { Oas2Decorator } from '../../visitors'; import type { Oas2Decorator } from '../../visitors';
import type { Oas2Components } from '../../typings/swagger'; import type { Oas2Components, Oas2Definition } from '../../typings/swagger';
export const RemoveUnusedComponents: Oas2Decorator = () => { export const RemoveUnusedComponents: Oas2Decorator = () => {
const components = new Map< const components = new Map<
string, string,
{ used: boolean; componentType?: keyof Oas2Components; name: string } { usedIn: Location[]; componentType?: keyof Oas2Components; name: string }
>(); >();
function registerComponent( function registerComponent(
@@ -16,15 +16,46 @@ export const RemoveUnusedComponents: Oas2Decorator = () => {
name: string name: string
): void { ): void {
components.set(location.absolutePointer, { components.set(location.absolutePointer, {
used: components.get(location.absolutePointer)?.used || false, usedIn: components.get(location.absolutePointer)?.usedIn ?? [],
componentType, componentType,
name, name,
}); });
} }
function removeUnusedComponents(root: Oas2Definition, removedPaths: string[]): number {
const removedLengthStart = removedPaths.length;
for (const [path, { usedIn, name, componentType }] of components) {
const used = usedIn.some(
(location) =>
!removedPaths.some(
(removed) =>
// Check if the current location's absolute pointer starts with the 'removed' path
// and either its length matches exactly with 'removed' or the character after the 'removed' path is a '/'
location.absolutePointer.startsWith(removed) &&
(location.absolutePointer.length === removed.length ||
location.absolutePointer[removed.length] === '/')
)
);
if (!used && componentType) {
removedPaths.push(path);
delete root[componentType]![name];
components.delete(path);
if (isEmptyObject(root[componentType])) {
delete root[componentType];
}
}
}
return removedPaths.length > removedLengthStart
? removeUnusedComponents(root, removedPaths)
: removedPaths.length;
}
return { return {
ref: { ref: {
leave(ref, { type, resolve, key }) { leave(ref, { location, type, resolve, key }) {
if (['Schema', 'Parameter', 'Response', 'SecurityScheme'].includes(type.name)) { if (['Schema', 'Parameter', 'Response', 'SecurityScheme'].includes(type.name)) {
const resolvedRef = resolve(ref); const resolvedRef = resolve(ref);
if (!resolvedRef.location) return; if (!resolvedRef.location) return;
@@ -33,32 +64,23 @@ export const RemoveUnusedComponents: Oas2Decorator = () => {
const componentLevelLocalPointer = localPointer.split('/').slice(0, 3).join('/'); const componentLevelLocalPointer = localPointer.split('/').slice(0, 3).join('/');
const pointer = `${fileLocation}#${componentLevelLocalPointer}`; const pointer = `${fileLocation}#${componentLevelLocalPointer}`;
components.set(pointer, { const registered = components.get(pointer);
used: true,
name: key.toString(), if (registered) {
}); registered.usedIn.push(location);
} else {
components.set(pointer, {
usedIn: [location],
name: key.toString(),
});
}
} }
}, },
}, },
Root: { Root: {
leave(root, ctx) { leave(root, ctx) {
const data = ctx.getVisitorData() as { removedCount: number }; const data = ctx.getVisitorData() as { removedCount: number };
data.removedCount = 0; data.removedCount = removeUnusedComponents(root, []);
const rootComponents = new Set<keyof Oas2Components>();
components.forEach((usageInfo) => {
const { used, name, componentType } = usageInfo;
if (!used && componentType) {
rootComponents.add(componentType);
delete root[componentType]![name];
data.removedCount++;
}
});
for (const component of rootComponents) {
if (isEmptyObject(root[component])) {
delete root[component];
}
}
}, },
}, },
NamedSchemas: { NamedSchemas: {

View File

@@ -168,4 +168,146 @@ describe('oas3 remove-unused-components', () => {
}, },
}); });
}); });
it('should remove transitively unused components', async () => {
const document = parseYamlToDocument(
outdent`
openapi: "3.0.0"
paths:
/pets:
get:
summary: List all pets
operationId: listPets
parameters:
- $ref: '#/components/parameters/used'
components:
parameters:
used:
name: used
unused:
name: unused
schemas:
Unused:
type: integer
enum:
- 1
- 2
Transitive:
type: object
properties:
link:
$ref: '#/components/schemas/Unused'
`,
'foobar.yaml'
);
const results = await bundleDocument({
externalRefResolver: new BaseResolver(),
document,
config: await makeConfig({}),
removeUnusedComponents: true,
});
expect(results.bundle.parsed).toEqual({
openapi: '3.0.0',
paths: {
'/pets': {
get: {
summary: 'List all pets',
operationId: 'listPets',
parameters: [
{
$ref: '#/components/parameters/used',
},
],
},
},
},
components: {
parameters: {
used: {
name: 'used',
},
},
},
});
});
it('should remove transitively unused components with colliding paths', async () => {
const document = parseYamlToDocument(
outdent`
openapi: "3.0.0"
paths:
/pets:
get:
responses:
200:
content:
application/json:
schema:
$ref: "#/components/schemas/Transitive2"
components:
schemas:
Unused: # <-- this will be removed correctly
type: integer
Transitive: # <-- this will be removed correctly
type: object
properties:
link:
$ref: '#/components/schemas/Unused'
Used:
type: integer
Transitive2:
type: object
properties:
link:
$ref: '#/components/schemas/Used'
`,
'foobar.yaml'
);
const results = await bundleDocument({
externalRefResolver: new BaseResolver(),
document,
config: await makeConfig({}),
removeUnusedComponents: true,
});
expect(results.bundle.parsed).toEqual({
openapi: '3.0.0',
paths: {
'/pets': {
get: {
responses: {
200: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Transitive2',
},
},
},
},
},
},
},
},
components: {
schemas: {
Transitive2: {
type: 'object',
properties: {
link: {
$ref: '#/components/schemas/Used',
},
},
},
Used: {
type: 'integer',
},
},
},
});
});
}); });

View File

@@ -2,12 +2,12 @@ import { Location } from '../../ref-utils';
import { isEmptyObject } from '../../utils'; import { isEmptyObject } from '../../utils';
import type { Oas3Decorator } from '../../visitors'; import type { Oas3Decorator } from '../../visitors';
import type { Oas3Components } from '../../typings/openapi'; import type { Oas3Components, Oas3Definition } from '../../typings/openapi';
export const RemoveUnusedComponents: Oas3Decorator = () => { export const RemoveUnusedComponents: Oas3Decorator = () => {
const components = new Map< const components = new Map<
string, string,
{ used: boolean; componentType?: keyof Oas3Components; name: string } { usedIn: Location[]; componentType?: keyof Oas3Components; name: string }
>(); >();
function registerComponent( function registerComponent(
@@ -16,15 +16,45 @@ export const RemoveUnusedComponents: Oas3Decorator = () => {
name: string name: string
): void { ): void {
components.set(location.absolutePointer, { components.set(location.absolutePointer, {
used: components.get(location.absolutePointer)?.used || false, usedIn: components.get(location.absolutePointer)?.usedIn ?? [],
componentType, componentType,
name, name,
}); });
} }
function removeUnusedComponents(root: Oas3Definition, removedPaths: string[]): number {
const removedLengthStart = removedPaths.length;
for (const [path, { usedIn, name, componentType }] of components) {
const used = usedIn.some(
(location) =>
!removedPaths.some(
(removed) =>
location.absolutePointer.startsWith(removed) &&
(location.absolutePointer.length === removed.length ||
location.absolutePointer[removed.length] === '/')
)
);
if (!used && componentType && root.components) {
removedPaths.push(path);
const componentChild = root.components[componentType];
delete componentChild![name];
components.delete(path);
if (isEmptyObject(componentChild)) {
delete root.components[componentType];
}
}
}
return removedPaths.length > removedLengthStart
? removeUnusedComponents(root, removedPaths)
: removedPaths.length;
}
return { return {
ref: { ref: {
leave(ref, { type, resolve, key }) { leave(ref, { location, type, resolve, key }) {
if ( if (
['Schema', 'Header', 'Parameter', 'Response', 'Example', 'RequestBody'].includes( ['Schema', 'Header', 'Parameter', 'Response', 'Example', 'RequestBody'].includes(
type.name type.name
@@ -37,29 +67,24 @@ export const RemoveUnusedComponents: Oas3Decorator = () => {
const componentLevelLocalPointer = localPointer.split('/').slice(0, 4).join('/'); const componentLevelLocalPointer = localPointer.split('/').slice(0, 4).join('/');
const pointer = `${fileLocation}#${componentLevelLocalPointer}`; const pointer = `${fileLocation}#${componentLevelLocalPointer}`;
components.set(pointer, { const registered = components.get(pointer);
used: true,
name: key.toString(), if (registered) {
}); registered.usedIn.push(location);
} else {
components.set(pointer, {
usedIn: [location],
name: key.toString(),
});
}
} }
}, },
}, },
Root: { Root: {
leave(root, ctx) { leave(root, ctx) {
const data = ctx.getVisitorData() as { removedCount: number }; const data = ctx.getVisitorData() as { removedCount: number };
data.removedCount = 0; data.removedCount = removeUnusedComponents(root, []);
components.forEach((usageInfo) => {
const { used, componentType, name } = usageInfo;
if (!used && componentType && root.components) {
const componentChild = root.components[componentType];
delete componentChild![name];
data.removedCount++;
if (isEmptyObject(componentChild)) {
delete root.components[componentType];
}
}
});
if (isEmptyObject(root.components)) { if (isEmptyObject(root.components)) {
delete root.components; delete root.components;
} }