diff --git a/.changeset/sharp-hairs-arrive.md b/.changeset/sharp-hairs-arrive.md new file mode 100644 index 0000000000..a7f922566a --- /dev/null +++ b/.changeset/sharp-hairs-arrive.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Extended scaffolder action `catalog:fetch` to fetch multiple catalog entities by entity references. diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index ca9b3543af..452689c2ea 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -89,7 +89,8 @@ export function createDebugLogAction(): TemplateAction_2<{ export function createFetchCatalogEntityAction(options: { catalogClient: CatalogApi; }): TemplateAction_2<{ - entityRef: string; + entityRef?: string | undefined; + entityRefs?: string[] | undefined; optional?: boolean | undefined; }>; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts index 52c76c8769..1cce7d66cb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts @@ -23,8 +23,11 @@ import { createFetchCatalogEntityAction } from './fetch'; describe('catalog:fetch', () => { const getEntityByRef = jest.fn(); + const getEntitiesByRefs = jest.fn(); + const catalogClient = { getEntityByRef: getEntityByRef, + getEntitiesByRefs: getEntitiesByRefs, }; const action = createFetchCatalogEntityAction({ @@ -71,25 +74,6 @@ describe('catalog:fetch', () => { }); }); - it('should return null if entity not in catalog and optional is true', async () => { - getEntityByRef.mockImplementationOnce(() => { - throw new Error('Not found'); - }); - - await action.handler({ - ...mockContext, - input: { - entityRef: 'component:default/test', - optional: true, - }, - }); - - expect(getEntityByRef).toHaveBeenCalledWith('component:default/test', { - token: 'secret', - }); - expect(mockContext.output).toHaveBeenCalledWith('entity', null); - }); - it('should throw error if entity fetch fails from catalog and optional is false', async () => { getEntityByRef.mockImplementationOnce(() => { throw new Error('Not found'); @@ -127,4 +111,114 @@ describe('catalog:fetch', () => { }); expect(mockContext.output).not.toHaveBeenCalled(); }); + + it('should return entities from catalog', async () => { + getEntitiesByRefs.mockReturnValueOnce({ + items: [ + { + metadata: { + namespace: 'default', + name: 'test', + }, + kind: 'Component', + } as Entity, + ], + }); + + await action.handler({ + ...mockContext, + input: { + entityRefs: ['component:default/test'], + }, + }); + + expect(getEntitiesByRefs).toHaveBeenCalledWith( + { entityRefs: ['component:default/test'] }, + { + token: 'secret', + }, + ); + expect(mockContext.output).toHaveBeenCalledWith('entities', [ + { + metadata: { + namespace: 'default', + name: 'test', + }, + kind: 'Component', + }, + ]); + }); + + it('should throw error if undefined is returned for some entity', async () => { + getEntitiesByRefs.mockReturnValueOnce({ + items: [ + { + metadata: { + namespace: 'default', + name: 'test', + }, + kind: 'Component', + } as Entity, + undefined, + ], + }); + + await expect( + action.handler({ + ...mockContext, + input: { + entityRefs: ['component:default/test', 'component:default/test2'], + optional: false, + }, + }), + ).rejects.toThrow('Entity component:default/test2 not found'); + + expect(getEntitiesByRefs).toHaveBeenCalledWith( + { entityRefs: ['component:default/test', 'component:default/test2'] }, + { + token: 'secret', + }, + ); + expect(mockContext.output).not.toHaveBeenCalled(); + }); + + it('should return null in case some of the entities not found and optional is true', async () => { + getEntitiesByRefs.mockReturnValueOnce({ + items: [ + { + metadata: { + namespace: 'default', + name: 'test', + }, + kind: 'Component', + } as Entity, + undefined, + ], + }); + + await action.handler({ + ...mockContext, + input: { + entityRefs: ['component:default/test', 'component:default/test2'], + optional: true, + }, + }); + + expect(getEntitiesByRefs).toHaveBeenCalledWith( + { entityRefs: ['component:default/test', 'component:default/test2'] }, + { + token: 'secret', + }, + ); + expect(mockContext.output).toHaveBeenCalledWith('entities', [ + { + metadata: { + namespace: 'default', + name: 'test', + }, + kind: 'Component', + }, + null, + ]); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts index 17e510ce64..1bcbfa0006 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts @@ -36,10 +36,26 @@ const examples = [ ], }), }, + { + description: 'Fetch multiple entities by referencse', + example: yaml.stringify({ + steps: [ + { + action: id, + id: 'fetchMultiple', + name: 'Fetch catalog entities', + input: { + entityRefs: ['component:default/name'], + }, + }, + ], + }), + }, ]; /** - * Returns entity from the catalog by entity reference. + * Returns entity or entities from the catalog by entity reference(s). + * * @public */ export function createFetchCatalogEntityAction(options: { @@ -47,13 +63,17 @@ export function createFetchCatalogEntityAction(options: { }) { const { catalogClient } = options; - return createTemplateAction<{ entityRef: string; optional?: boolean }>({ + return createTemplateAction<{ + entityRef?: string; + entityRefs?: string[]; + optional?: boolean; + }>({ id, - description: 'Returns entity from the catalog by entity reference', + description: + 'Returns entity or entities from the catalog by entity reference(s)', examples, schema: { input: { - required: ['entityRef'], type: 'object', properties: { entityRef: { @@ -61,10 +81,15 @@ export function createFetchCatalogEntityAction(options: { title: 'Entity reference', description: 'Entity reference of the entity to get', }, + entityRefs: { + type: 'array', + title: 'Entity references', + description: 'Entity references of the entities to get', + }, optional: { title: 'Optional', description: - 'Permit the entity to optionally exist. Default: false', + 'Allow the entity or entities to optionally exist. Default: false', type: 'boolean', }, }, @@ -76,29 +101,55 @@ export function createFetchCatalogEntityAction(options: { title: 'Entity found by the entity reference', type: 'object', description: - 'Object containing same values used in the Entity schema.', + 'Object containing same values used in the Entity schema. Only when used with `entityRef` parameter.', + }, + entities: { + title: 'Entities found by the entity references', + type: 'array', + items: { type: 'object' }, + description: + 'Array containing objects with same values used in the Entity schema. Only when used with `entityRefs` parameter.', }, }, }, }, async handler(ctx) { - const { entityRef, optional } = ctx.input; - let entity; - try { - entity = await catalogClient.getEntityByRef(entityRef, { + const { entityRef, entityRefs, optional } = ctx.input; + if (!entityRef && !entityRefs) { + if (optional) { + return; + } + throw new Error('Missing entity reference or references'); + } + + if (entityRef) { + const entity = await catalogClient.getEntityByRef(entityRef, { token: ctx.secrets?.backstageToken, }); - } catch (e) { - if (!optional) { - throw e; + + if (!entity && !optional) { + throw new Error(`Entity ${entityRef} not found`); } + ctx.output('entity', entity ?? null); } - if (!entity && !optional) { - throw new Error(`Entity ${entityRef} not found`); - } + if (entityRefs) { + const entities = await catalogClient.getEntitiesByRefs( + { entityRefs }, + { + token: ctx.secrets?.backstageToken, + }, + ); - ctx.output('entity', entity ?? null); + const finalEntities = entities.items.map((e, i) => { + if (!e && !optional) { + throw new Error(`Entity ${entityRefs[i]} not found`); + } + return e ?? null; + }); + + ctx.output('entities', finalEntities); + } }, }); }