diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 58474f4dbc..404d201a44 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -168,7 +168,11 @@ export async function createRouter( res.setHeader('link', `<${url.pathname}${url.search}>; rel="next"`); } - await writeEntitiesResponse(res, entities); + await writeEntitiesResponse({ + res, + items: entities, + alwaysUseObjectMode: !disableRelationsCompatibility, + }); return; } @@ -214,18 +218,23 @@ export async function createRouter( credentials: await httpAuth.credentials(req), }); - await writeEntitiesResponse(res, items, entities => ({ - items: entities, - totalItems, - pageInfo: { - ...(pageInfo.nextCursor && { - nextCursor: encodeCursor(pageInfo.nextCursor), - }), - ...(pageInfo.prevCursor && { - prevCursor: encodeCursor(pageInfo.prevCursor), - }), - }, - })); + await writeEntitiesResponse({ + res, + items, + alwaysUseObjectMode: !disableRelationsCompatibility, + responseWrapper: entities => ({ + items: entities, + totalItems, + pageInfo: { + ...(pageInfo.nextCursor && { + nextCursor: encodeCursor(pageInfo.nextCursor), + }), + ...(pageInfo.prevCursor && { + prevCursor: encodeCursor(pageInfo.prevCursor), + }), + }, + }), + }); }) .get('/entities/by-uid/:uid', async (req, res) => { const { uid } = req.params; @@ -273,9 +282,14 @@ export async function createRouter( fields: parseEntityTransformParams(req.query, request.fields), credentials: await httpAuth.credentials(req), }); - await writeEntitiesResponse(res, items, entities => ({ - items: entities, - })); + await writeEntitiesResponse({ + res, + items, + alwaysUseObjectMode: !disableRelationsCompatibility, + responseWrapper: entities => ({ + items: entities, + }), + }); }) .get('/entity-facets', async (req, res) => { const response = await entitiesCatalog.facets({ diff --git a/plugins/catalog-backend/src/service/response/write.test.ts b/plugins/catalog-backend/src/service/response/write.test.ts index fdca874904..d60d1c3526 100644 --- a/plugins/catalog-backend/src/service/response/write.test.ts +++ b/plugins/catalog-backend/src/service/response/write.test.ts @@ -128,14 +128,23 @@ describe('writeEntitiesResponse', () => { const app = express(); app.use(express.json()); app.get('/echo', (req, res) => { - writeEntitiesResponse(res, req.body); + writeEntitiesResponse({ + res, + items: req.body, + alwaysUseObjectMode: Boolean(req.query.object), + }); }); app.get('/wrapped', (req, res) => { - writeEntitiesResponse(res, req.body, entities => ({ - page: 1, - items: entities, - totalItems: 1337, - })); + writeEntitiesResponse({ + res, + items: req.body, + alwaysUseObjectMode: Boolean(req.query.object), + responseWrapper: entities => ({ + page: 1, + items: entities, + totalItems: 1337, + }), + }); }); app.use(mockErrorHandler()); @@ -151,6 +160,7 @@ describe('writeEntitiesResponse', () => { expect(res.header['content-type']).toBe( 'application/json; charset=utf-8', ); + expect(res.header['content-length']).toBeDefined(); expect(res.body).toEqual([]); }); @@ -167,6 +177,7 @@ describe('writeEntitiesResponse', () => { expect(res.header['content-type']).toBe( 'application/json; charset=utf-8', ); + expect(res.header['content-length']).toBeDefined(); expect(res.body).toEqual([ { kind: 'Component' }, null, @@ -185,6 +196,7 @@ describe('writeEntitiesResponse', () => { expect(res.header['content-type']).toBe( 'application/json; charset=utf-8', ); + expect(res.header['content-length']).toBeDefined(); expect(res.body).toEqual({ page: 1, items: [], totalItems: 1337 }); }); @@ -201,6 +213,7 @@ describe('writeEntitiesResponse', () => { expect(res.header['content-type']).toBe( 'application/json; charset=utf-8', ); + expect(res.header['content-length']).toBeDefined(); expect(res.body).toEqual({ page: 1, items: [{ kind: 'Component' }, null, { kind: 'User' }, null], @@ -221,6 +234,7 @@ describe('writeEntitiesResponse', () => { expect(res.header['content-type']).toBe( 'application/json; charset=utf-8', ); + expect(res.header['content-length']).toBeDefined(); expect(res.body).toEqual([]); }); @@ -237,6 +251,7 @@ describe('writeEntitiesResponse', () => { expect(res.header['content-type']).toBe( 'application/json; charset=utf-8', ); + expect(res.header['content-length']).not.toBeDefined(); expect(res.body).toEqual([ { kind: 'Component' }, null, @@ -255,6 +270,7 @@ describe('writeEntitiesResponse', () => { expect(res.header['content-type']).toBe( 'application/json; charset=utf-8', ); + expect(res.header['content-length']).not.toBeDefined(); expect(res.body).toEqual({ page: 1, items: [], totalItems: 1337 }); }); @@ -271,6 +287,7 @@ describe('writeEntitiesResponse', () => { expect(res.header['content-type']).toBe( 'application/json; charset=utf-8', ); + expect(res.header['content-length']).not.toBeDefined(); expect(res.body).toEqual({ page: 1, items: [{ kind: 'Component' }, null, { kind: 'User' }, null], @@ -307,6 +324,118 @@ describe('writeEntitiesResponse', () => { expect(res.header['content-type']).toBe( 'application/json; charset=utf-8', ); + expect(res.header['content-length']).not.toBeDefined(); + expect(res.body).toEqual({ + page: 1, + items: expect.objectContaining({ length: 300 }), + totalItems: 1337, + }); + }); + }); + + describe('in raw form forced to object', () => { + it('should return empty list', async () => { + const res = await request(app).get('/echo?object=true').send({ + type: 'raw', + entities: [], + }); + + expect(res.status).toBe(200); + expect(res.type).toBe('application/json'); + expect(res.header['content-type']).toBe( + 'application/json; charset=utf-8', + ); + expect(res.header['content-length']).toBeDefined(); + expect(res.body).toEqual([]); + }); + + it('should return mixed objects', async () => { + const res = await request(app) + .get('/echo?object=true') + .send({ + type: 'raw', + entities: ['{"kind":"Component"}', null, '{"kind":"User"}', null], + }); + + expect(res.status).toBe(200); + expect(res.type).toBe('application/json'); + expect(res.header['content-type']).toBe( + 'application/json; charset=utf-8', + ); + expect(res.header['content-length']).toBeDefined(); + expect(res.body).toEqual([ + { kind: 'Component' }, + null, + { kind: 'User' }, + null, + ]); + }); + + it('should wrap response of empty list', async () => { + const res = await request(app) + .get('/wrapped?object=true') + .send({ type: 'raw', entities: [] }); + + expect(res.status).toBe(200); + expect(res.type).toBe('application/json'); + expect(res.header['content-type']).toBe( + 'application/json; charset=utf-8', + ); + expect(res.header['content-length']).toBeDefined(); + expect(res.body).toEqual({ page: 1, items: [], totalItems: 1337 }); + }); + + it('should wrap response of mixed list', async () => { + const res = await request(app) + .get('/wrapped?object=true') + .send({ + type: 'raw', + entities: ['{"kind":"Component"}', null, '{"kind":"User"}', null], + }); + + expect(res.status).toBe(200); + expect(res.type).toBe('application/json'); + expect(res.header['content-type']).toBe( + 'application/json; charset=utf-8', + ); + expect(res.header['content-length']).toBeDefined(); + expect(res.body).toEqual({ + page: 1, + items: [{ kind: 'Component' }, null, { kind: 'User' }, null], + totalItems: 1337, + }); + }); + + it('should write a large wrapped response', async () => { + const entityMock = JSON.stringify({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'default', + annotations: { + 'backstage.io/managed-by-location': 'url:https://example.com', + }, + }, + spec: { + type: 'service', + owner: 'me', + lifecycle: 'production', + }, + }); + const res = await request(app) + .get('/wrapped?object=true') + .send({ + type: 'raw', + entities: Array(300).fill(entityMock), + }); + + expect(res.status).toBe(200); + expect(res.type).toBe('application/json'); + expect(res.header['content-type']).toBe( + 'application/json; charset=utf-8', + ); + expect(res.header['content-length']).toBeDefined(); expect(res.body).toEqual({ page: 1, items: expect.objectContaining({ length: 300 }), diff --git a/plugins/catalog-backend/src/service/response/write.ts b/plugins/catalog-backend/src/service/response/write.ts index b313e3a630..b43064a1e4 100644 --- a/plugins/catalog-backend/src/service/response/write.ts +++ b/plugins/catalog-backend/src/service/response/write.ts @@ -18,6 +18,7 @@ import { Response } from 'express'; import { EntitiesResponseItems } from '../../catalog/types'; import { JsonValue } from '@backstage/types'; import { NotFoundError } from '@backstage/errors'; +import { processEntitiesResponseItems } from './process'; const JSON_CONTENT_TYPE = 'application/json; charset=utf-8'; @@ -42,16 +43,20 @@ export function writeSingleEntityResponse( } } -export async function writeEntitiesResponse( - res: Response, - response: EntitiesResponseItems, - responseWrapper?: (entities: JsonValue) => JsonValue, -) { - if (response.type === 'object') { +export async function writeEntitiesResponse(options: { + res: Response; + items: EntitiesResponseItems; + responseWrapper?: (entities: JsonValue) => JsonValue; + alwaysUseObjectMode?: boolean; +}) { + const { res, responseWrapper, alwaysUseObjectMode } = options; + const items = alwaysUseObjectMode + ? processEntitiesResponseItems(options.items, e => e) + : options.items; + + if (items.type === 'object') { res.json( - responseWrapper - ? responseWrapper?.(response.entities) - : response.entities, + responseWrapper ? responseWrapper?.(items.entities) : items.entities, ); return; } @@ -74,7 +79,7 @@ export async function writeEntitiesResponse( } let first = true; - for (const entity of response.entities) { + for (const entity of items.entities) { const prefix = first ? '[' : ','; first = false;