catalog-backend: respose item type object + single entity refactor and tests

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-12-12 13:30:11 +01:00
parent 02bd2cb19c
commit 6f09b9af99
7 changed files with 170 additions and 46 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ export type EntitiesRequest = {
*/
export type EntitiesResponseItems =
| {
type: 'objects';
type: 'object';
entities: (Entity | null)[];
}
| {
@@ -60,7 +60,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
if (authorizeDecision.result === AuthorizeResult.DENY) {
return {
entities: { type: 'objects', entities: [] },
entities: { type: 'object', entities: [] },
pageInfo: { hasNextPage: false },
};
}
@@ -93,7 +93,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
if (authorizeDecision.result === AuthorizeResult.DENY) {
return {
items: {
type: 'objects',
type: 'object',
entities: new Array(request.entityRefs.length).fill(null),
},
};
@@ -126,7 +126,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
if (authorizeDecision.result === AuthorizeResult.DENY) {
return {
items: { type: 'objects', entities: [] },
items: { type: 'object', entities: [] },
pageInfo: {},
totalItems: 0,
};
@@ -136,7 +136,7 @@ describe('createRouter readonly disabled', () => {
];
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: { type: 'objects', entities: [entities[0]] },
items: { type: 'object', entities: [entities[0]] },
pageInfo: {},
totalItems: 1,
});
@@ -149,7 +149,7 @@ describe('createRouter readonly disabled', () => {
it('parses single and multiple request parameters and passes them down', async () => {
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: { type: 'objects', entities: [] },
items: { type: 'object', entities: [] },
pageInfo: {},
totalItems: 0,
});
@@ -185,7 +185,7 @@ describe('createRouter readonly disabled', () => {
];
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: { type: 'objects', entities: items },
items: { type: 'object', entities: items },
totalItems: 100,
pageInfo: { nextCursor: mockCursor() },
});
@@ -203,7 +203,7 @@ describe('createRouter readonly disabled', () => {
it('parses initial request', async () => {
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: { type: 'objects', entities: [] },
items: { type: 'object', entities: [] },
pageInfo: {},
totalItems: 0,
});
@@ -239,7 +239,7 @@ describe('createRouter readonly disabled', () => {
it('parses encoded params request', async () => {
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: { type: 'objects', entities: [] },
items: { type: 'object', entities: [] },
pageInfo: {},
totalItems: 0,
});
@@ -283,7 +283,7 @@ describe('createRouter readonly disabled', () => {
];
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: { type: 'objects', entities: items },
items: { type: 'object', entities: items },
totalItems: 100,
pageInfo: { nextCursor: mockCursor() },
});
@@ -312,7 +312,7 @@ describe('createRouter readonly disabled', () => {
];
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: { type: 'objects', entities: items },
items: { type: 'object', entities: items },
totalItems: 100,
pageInfo: {
nextCursor: mockCursor({ fullTextFilter: { term: 'mySearch' } }),
@@ -354,7 +354,7 @@ describe('createRouter readonly disabled', () => {
];
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: { type: 'objects', entities: items },
items: { type: 'object', entities: items },
totalItems: 100,
pageInfo: { nextCursor: mockCursor() },
});
@@ -379,7 +379,7 @@ describe('createRouter readonly disabled', () => {
];
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: { type: 'objects', entities: items },
items: { type: 'object', entities: items },
totalItems: 100,
pageInfo: { nextCursor: mockCursor() },
});
@@ -402,7 +402,7 @@ describe('createRouter readonly disabled', () => {
},
};
entitiesCatalog.entities.mockResolvedValue({
entities: { type: 'objects', entities: [entity] },
entities: { type: 'object', entities: [entity] },
pageInfo: { hasNextPage: false },
});
@@ -419,7 +419,7 @@ describe('createRouter readonly disabled', () => {
it('responds with a 404 for missing entities', async () => {
entitiesCatalog.entities.mockResolvedValue({
entities: { type: 'objects', entities: [] },
entities: { type: 'object', entities: [] },
pageInfo: { hasNextPage: false },
});
@@ -446,7 +446,7 @@ describe('createRouter readonly disabled', () => {
},
};
entitiesCatalog.entitiesBatch.mockResolvedValue({
items: { type: 'objects', entities: [entity] },
items: { type: 'object', entities: [entity] },
});
const response = await request(app).get('/entities/by-name/k/ns/n');
@@ -462,7 +462,7 @@ describe('createRouter readonly disabled', () => {
it('responds with a 404 for missing entities', async () => {
entitiesCatalog.entitiesBatch.mockResolvedValue({
items: { type: 'objects', entities: [null] },
items: { type: 'object', entities: [null] },
});
const response = await request(app).get('/entities/by-name/b/d/c');
@@ -534,7 +534,7 @@ describe('createRouter readonly disabled', () => {
};
const entityRef = stringifyEntityRef(entity);
entitiesCatalog.entitiesBatch.mockResolvedValue({
items: { type: 'objects', entities: [entity] },
items: { type: 'object', entities: [entity] },
});
const response = await request(app)
.post('/entities/by-refs?filter=kind=Component')
@@ -910,7 +910,7 @@ describe('createRouter readonly enabled', () => {
];
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: { type: 'objects', entities: [entities[0]] },
items: { type: 'object', entities: [entities[0]] },
pageInfo: {},
totalItems: 1,
});
@@ -1130,7 +1130,7 @@ describe('NextRouter permissioning', () => {
},
};
entitiesCatalog.entities.mockResolvedValueOnce({
entities: { type: 'objects', entities: [spideySense] },
entities: { type: 'object', entities: [spideySense] },
pageInfo: { hasNextPage: false },
});
@@ -23,7 +23,7 @@ import {
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { InputError, NotFoundError, serializeError } from '@backstage/errors';
import { InputError, serializeError } from '@backstage/errors';
import express from 'express';
import yn from 'yn';
import { z } from 'zod';
@@ -272,9 +272,7 @@ export async function createRouter(
filter: basicEntityFilter({ 'metadata.uid': uid }),
credentials: await httpAuth.credentials(req),
});
if (!writeSingleEntityResponse(res, entities)) {
throw new NotFoundError(`No entity with uid ${uid}`);
}
writeSingleEntityResponse(res, entities, `No entity with uid ${uid}`);
})
.delete('/entities/by-uid/:uid', async (req, res) => {
const { uid } = req.params;
@@ -289,11 +287,11 @@ export async function createRouter(
entityRefs: [stringifyEntityRef({ kind, namespace, name })],
credentials: await httpAuth.credentials(req),
});
if (!writeSingleEntityResponse(res, items)) {
throw new NotFoundError(
`No entity named '${name}' found, with kind '${kind}' in namespace '${namespace}'`,
);
}
writeSingleEntityResponse(
res,
items,
`No entity named '${name}' found, with kind '${kind}' in namespace '${namespace}'`,
);
})
.get(
'/entities/by-name/:kind/:namespace/:name/ancestry',
@@ -23,7 +23,7 @@ export function processRawEntitiesResult(
): EntitiesResponseItems {
if (transform) {
return {
type: 'objects',
type: 'object',
entities: serializedEntities.map(e =>
e !== null ? transform(JSON.parse(e)) : e,
),
@@ -47,7 +47,7 @@ export function processEntitiesResponseItems(
return processRawEntitiesResult(response.entities, transform);
}
return {
type: 'objects',
type: 'object',
entities: response.entities.map(e => (e !== null ? transform(e) : e)),
};
}
@@ -55,7 +55,7 @@ export function processEntitiesResponseItems(
export function entitiesResponseToObjects(
response: EntitiesResponseItems,
): (Entity | null)[] {
if (response.type === 'objects') {
if (response.type === 'object') {
return response.entities;
}
return response.entities.map(e => (e !== null ? JSON.parse(e) : e));
@@ -0,0 +1,125 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
import { mockErrorHandler } from '@backstage/backend-test-utils';
import request from 'supertest';
import { writeSingleEntityResponse } from './write';
describe('writeSingleEntityResponse', () => {
const app = express();
app.use(express.json());
app.get('/echo', (req, res) => {
writeSingleEntityResponse(res, req.body, 'not found');
});
app.use(mockErrorHandler());
describe('in object form', () => {
it('should write a single entity', async () => {
const res = await request(app)
.get('/echo')
.send({
type: 'object',
entities: [{ kind: 'Component' }, { kind: 'User' }],
});
expect(res.status).toBe(200);
expect(res.type).toBe('application/json');
expect(res.header['content-type']).toBe(
'application/json; charset=utf-8',
);
expect(res.body).toEqual({ kind: 'Component' });
});
it('should write a missing entity', async () => {
const res = await request(app)
.get('/echo')
.send({ type: 'object', entities: [null] });
expect(res.status).toBe(404);
expect(res.type).toBe('application/json');
expect(res.header['content-type']).toBe(
'application/json; charset=utf-8',
);
expect(res.body).toMatchObject({
error: { name: 'NotFoundError', message: 'not found' },
});
});
it('should write no entities', async () => {
const res = await request(app)
.get('/echo')
.send({ type: 'object', entities: [] });
expect(res.status).toBe(404);
expect(res.type).toBe('application/json');
expect(res.header['content-type']).toBe(
'application/json; charset=utf-8',
);
expect(res.body).toMatchObject({
error: { name: 'NotFoundError', message: 'not found' },
});
});
});
describe('in raw form', () => {
it('should write a single entity', async () => {
const res = await request(app)
.get('/echo')
.send({
type: 'raw',
entities: ['{"kind":"Component"}', '{"kind":"User"}'],
});
expect(res.status).toBe(200);
expect(res.type).toBe('application/json');
expect(res.header['content-type']).toBe(
'application/json; charset=utf-8',
);
expect(res.body).toEqual({ kind: 'Component' });
});
it('should write a missing entity', async () => {
const res = await request(app)
.get('/echo')
.send({ type: 'raw', entities: [null] });
expect(res.status).toBe(404);
expect(res.type).toBe('application/json');
expect(res.header['content-type']).toBe(
'application/json; charset=utf-8',
);
expect(res.body).toMatchObject({
error: { name: 'NotFoundError', message: 'not found' },
});
});
it('should write no entities', async () => {
const res = await request(app)
.get('/echo')
.send({ type: 'raw', entities: [] });
expect(res.status).toBe(404);
expect(res.type).toBe('application/json');
expect(res.header['content-type']).toBe(
'application/json; charset=utf-8',
);
expect(res.body).toMatchObject({
error: { name: 'NotFoundError', message: 'not found' },
});
});
});
});
@@ -17,27 +17,29 @@
import { Response } from 'express';
import { EntitiesResponseItems } from '../../catalog/types';
import { JsonValue } from '@backstage/types';
import { NotFoundError } from '@backstage/errors';
const JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
export function writeSingleEntityResponse(
res: Response,
response: EntitiesResponseItems,
): boolean {
const entity = response.entities[0];
if (!entity) {
return false;
}
notFoundMessage: string,
) {
if (response.type === 'object') {
if (!response.entities[0]) {
throw new NotFoundError(notFoundMessage);
}
if (typeof entity === 'string') {
res.setHeader('Content-Type', JSON_CONTENT_TYPE);
res.status(200);
res.write(entity);
res.json(response.entities[0]);
} else {
res.json(entity);
}
if (!response.entities[0]) {
throw new NotFoundError(notFoundMessage);
}
return true;
res.setHeader('Content-Type', JSON_CONTENT_TYPE);
res.end(response.entities[0]);
}
}
export function writeEntitiesResponse(
@@ -45,7 +47,7 @@ export function writeEntitiesResponse(
response: EntitiesResponseItems,
responseWrapper?: (entities: JsonValue) => JsonValue,
) {
if (response.type === 'objects') {
if (response.type === 'object') {
res.json(
responseWrapper
? responseWrapper?.(response.entities)
@@ -55,7 +57,6 @@ export function writeEntitiesResponse(
}
res.setHeader('Content-Type', JSON_CONTENT_TYPE);
res.status(200);
// responseWrapper allows the caller to render the entities within an object
let trailing = '';