diff --git a/.changeset/popular-cameras-worry.md b/.changeset/popular-cameras-worry.md new file mode 100644 index 0000000000..a5cbd6562b --- /dev/null +++ b/.changeset/popular-cameras-worry.md @@ -0,0 +1,6 @@ +--- +'@backstage/catalog-client': patch +'@backstage/plugin-catalog-backend': patch +--- + +Enable the `by-refs` endpoint to receive `fields` through the POST body as well as through query parameters. diff --git a/docs/features/software-catalog/api.md b/docs/features/software-catalog/api.md index afd9a0af93..3ca7e5ef49 100644 --- a/docs/features/software-catalog/api.md +++ b/docs/features/software-catalog/api.md @@ -246,6 +246,37 @@ value. These are special in that they form the entity's unique The return type is JSON, as a single [`Entity`](descriptor-format.md), or a 404 error if there was no entity with that reference triplet. +### `POST /entities/by-refs` + +Gets a batch of entities by their entity refs. This is useful in contexts where +you want to fetch a large number of specific entities efficiently, for example +in GraphQL resolvers. + +The request body is JSON, on the form + +```json +{ + "entityRefs": ["component:default/foo", "api:default/bar"], + "fields": ["kind", "metadata.name"] +} +``` + +where each `entityRefs` entry is an entity ref that you want to fetch. The +`fields` array is optional and works the same way as the `GET /entities` fields +above, e.g. it's used to fetch only certain slices of each entity. + +The return type is JSON, on the form + +```json +{ + "items": [{ "kind": "Component", "metadata": { "name": "foo" } }, null] +} +``` + +where the `items` array has _the same length_ and _the same order_ as the input +`entityRefs` array. Each element contains the corresponding entity data, or +`null` if no entity existed in the catalog with that ref. + ## Locations TODO diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 8587a5a2e1..2e9d2a0679 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -232,9 +232,9 @@ describe('CatalogClient', () => { }; server.use( rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => { - expect(req.url.searchParams.get('fields')).toBe('a,b'); await expect(req.json()).resolves.toEqual({ entityRefs: ['k:n/a', 'k:n/b'], + fields: ['a', 'b'], }); return res(ctx.json({ items: [entity, null] })); }), diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 81f7343555..af4262b842 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -197,14 +197,13 @@ export class CatalogClient implements CatalogApi { request: GetEntitiesByRefsRequest, options?: CatalogRequestOptions, ): Promise { - const params: string[] = []; + const body: any = { entityRefs: request.entityRefs }; if (request.fields?.length) { - params.push(`fields=${request.fields.map(encodeURIComponent).join(',')}`); + body.fields = request.fields; } const baseUrl = await this.discoveryApi.getBaseUrl('catalog'); - const query = params.length ? `?${params.join('&')}` : ''; - const url = `${baseUrl}/entities/by-refs${query}`; + const url = `${baseUrl}/entities/by-refs`; const response = await this.fetchApi.fetch(url, { headers: { @@ -212,7 +211,7 @@ export class CatalogClient implements CatalogApi { ...(options?.token && { Authorization: `Bearer ${options?.token}` }), }, method: 'POST', - body: JSON.stringify({ entityRefs: request.entityRefs }), + body: JSON.stringify(body), }); if (!response.ok) { diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 541359feb6..6379d65043 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -268,6 +268,8 @@ describe('createRouter readonly disabled', () => { '{"unknown":7}', '{"entityRefs":7}', '{"entityRefs":[7]}', + '{"entityRefs":[7],"fields":7}', + '{"entityRefs":[7],"fields":[7]}', ])('properly rejects malformed request body, %p', async p => { await expect( request(app) @@ -283,8 +285,12 @@ describe('createRouter readonly disabled', () => { const response = await request(app) .post('/entities/by-refs') .set('Content-Type', 'application/json') - .send('{"entityRefs":["a"]}'); + .send('{"entityRefs":["a"],"fields":["b"]}'); expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledWith({ + entityRefs: ['a'], + fields: expect.any(Function), + }); expect(response.status).toEqual(200); expect(response.body).toEqual({ items: [entity] }); }); diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 6a31fcd0b6..62a0f970c9 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -181,7 +181,7 @@ export async function createRouter( const token = getBearerToken(req.header('authorization')); const response = await entitiesCatalog.entitiesBatch({ entityRefs: request.entityRefs, - fields: parseEntityTransformParams(req.query), + fields: parseEntityTransformParams(req.query, request.fields), authorizationToken: token, }); res.status(200).json(response); diff --git a/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts index b3a91f9491..d5469315be 100644 --- a/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts +++ b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts @@ -20,9 +20,10 @@ import { z } from 'zod'; const schema = z.object({ entityRefs: z.array(z.string()), + fields: z.array(z.string()).optional(), }); -export function entitiesBatchRequest(req: Request) { +export function entitiesBatchRequest(req: Request): z.infer { try { return schema.parse(req.body); } catch (error) { diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts index 3dce579ae5..16141e401c 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts @@ -18,22 +18,26 @@ import { Entity } from '@backstage/catalog-model'; import { parseEntityTransformParams } from './parseEntityTransformParams'; describe('parseEntityTransformParams', () => { - const entity: Entity = { - apiVersion: 'av', - kind: 'k', - metadata: { - name: 'n', - tags: ['t1', 't2'], - annotations: { - 'example.test/url-like-key': 'ul1', - 'example.com/other-url-like-key': 'ul2', - 'other-example.test/next-url-like-key': 'ul3', + let entity: Entity; + + beforeEach(() => { + entity = { + apiVersion: 'av', + kind: 'k', + metadata: { + name: 'n', + tags: ['t1', 't2'], + annotations: { + 'example.test/url-like-key': 'ul1', + 'example.com/other-url-like-key': 'ul2', + 'other-example.test/next-url-like-key': 'ul3', + }, }, - }, - spec: { - type: 't', - }, - }; + spec: { + type: 't', + }, + }; + }); it('returns undefined when no fields given', () => { expect(parseEntityTransformParams({})).toBeUndefined(); @@ -46,7 +50,9 @@ describe('parseEntityTransformParams', () => { it('rejects attempts at array filtering', () => { expect(() => parseEntityTransformParams({ fields: 'metadata.tags[0]' })!(entity), - ).toThrow(/invalid fields, array type fields are not supported/i); + ).toThrow( + 'Invalid field "metadata.tags[0]", array type fields are not supported', + ); }); it('accepts both strings and arrays of strings as input', () => { @@ -177,4 +183,18 @@ describe('parseEntityTransformParams', () => { ), ).toEqual({ kind: 'k' }); }); + + it('handles both query params and extras, dealing with overlaps', () => { + expect( + parseEntityTransformParams({ fields: 'kind' }, [ + 'kind', + 'metadata.name', + ])!(entity), + ).toEqual({ + kind: 'k', + metadata: { + name: 'n', + }, + }); + }); }); diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts index cef6e5ef64..e203450103 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts @@ -38,24 +38,28 @@ function getPathArrayAndValue(input: Entity, field: string) { export function parseEntityTransformParams( params: Record, + extra?: string[], ): ((entity: Entity) => Entity) | undefined { - const fieldsStrings = parseStringsParam(params.fields, 'fields'); - if (!fieldsStrings) { - return undefined; - } + const queryFields = parseStringsParam(params.fields, 'fields'); - const fields = fieldsStrings - .map(s => s.split(',')) - .flat() - .map(s => s.trim()) - .filter(Boolean); + const fields = Array.from( + new Set( + [...(extra ?? []), ...(queryFields?.map(s => s.split(',')) ?? [])] + .flat() + .map(s => s.trim()) + .filter(Boolean), + ), + ); if (!fields.length) { return undefined; } - if (fields.some(f => f.includes('['))) { - throw new InputError('invalid fields, array type fields are not supported'); + const arrayTypeField = fields.find(f => f.includes('[')); + if (arrayTypeField) { + throw new InputError( + `Invalid field "${arrayTypeField}", array type fields are not supported`, + ); } return input => {