feat(openapi-tooling): Add support for request validation and create router stubs.

Signed-off-by: Aramis Sennyey <sennyeyaramis@gmail.com>
Signed-off-by: Aramis <sennyeyaramis@gmail.com>
This commit is contained in:
Aramis Sennyey
2023-05-11 14:09:59 -04:00
committed by Aramis
parent f8be6ff22f
commit ebeb775869
35 changed files with 801 additions and 282 deletions
+1 -1
View File
@@ -46,6 +46,7 @@
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-openapi-utils": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
"@backstage/catalog-client": "workspace:^",
@@ -87,7 +88,6 @@
"zod": "^3.21.4"
},
"devDependencies": {
"@backstage/backend-openapi-utils": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
@@ -518,7 +518,7 @@ describe('jsonPlaceholderResolver', () => {
it('rejects invalid json', async () => {
read.mockResolvedValue(Buffer.from('}', 'utf-8'));
await expect(jsonPlaceholderResolver(params)).rejects.toThrow(
'Placeholder $a failed to parse JSON data at ./file.json, SyntaxError: Unexpected token } in JSON at position 0',
'Placeholder $a failed to parse JSON data at ./file.json',
);
});
});
@@ -17,9 +17,10 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { createValidatedOpenApiRouter } from '@backstage/backend-openapi-utils';
export default {
openapi: '3.1.0',
export const spec = {
openapi: '3.0.3',
info: {
title: '@backstage/plugin-catalog-backend',
version: '1',
@@ -97,8 +98,12 @@ export default {
in: 'query',
description: 'Restrict to just these fields in the response.',
required: false,
allowReserved: true,
schema: {
type: 'string',
type: 'array',
items: {
type: 'string',
},
},
},
filter: {
@@ -107,7 +112,10 @@ export default {
description: 'Filter for just the entities defined by this filter.',
required: false,
schema: {
type: 'string',
type: 'array',
items: {
type: 'string',
},
},
},
offset: {
@@ -130,18 +138,16 @@ export default {
minimum: 1,
},
},
sortField: {
name: 'sortField',
orderField: {
name: 'orderField',
in: 'query',
description: 'The fields to sort returned results by.',
required: false,
allowReserved: true,
schema: {
type: 'array',
items: {
type: 'array',
items: {
type: 'string',
},
type: 'string',
description: 'A two-item tuple of [field, order].',
},
},
@@ -256,7 +262,7 @@ export default {
},
],
description: 'Metadata fields common to all versions/kinds of entity.',
additionalProperties: false,
additionalProperties: true,
},
EntityRelation: {
type: 'object',
@@ -305,7 +311,40 @@ export default {
required: ['metadata', 'kind', 'apiVersion'],
description:
"The parts of the format that's common to all versions/kinds of entity.",
additionalProperties: false,
additionalProperties: true,
},
NullableEntity: {
type: 'object',
properties: {
relations: {
type: 'array',
items: {
$ref: '#/components/schemas/EntityRelation',
},
description:
'The relations that this entity has with other entities.',
},
spec: {
$ref: '#/components/schemas/JsonObject',
},
metadata: {
$ref: '#/components/schemas/EntityMeta',
},
kind: {
type: 'string',
description: 'The high level entity type being described.',
},
apiVersion: {
type: 'string',
description:
'The version of specification format for this particular entity that\nthis is written against.',
},
},
required: ['metadata', 'kind', 'apiVersion'],
description:
"The parts of the format that's common to all versions/kinds of entity.",
additionalProperties: true,
nullable: true,
},
EntityAncestryResponse: {
type: 'object',
@@ -343,10 +382,7 @@ export default {
items: {
anyOf: [
{
$ref: '#/components/schemas/Entity',
},
{
type: 'null',
$ref: '#/components/schemas/NullableEntity',
},
],
},
@@ -405,10 +441,6 @@ export default {
LocationSpec: {
type: 'object',
properties: {
presence: {
type: 'string',
enum: ['optional', 'required'],
},
target: {
type: 'string',
},
@@ -559,7 +591,8 @@ export default {
'A text to show to the user to inform about the choices made. Like, it could say\n"Found a CODEOWNERS file that covers this target, so we suggest leaving this\nfield empty; which would currently make it owned by X" where X is taken from the\ncodeowners file.',
},
value: {
type: ['string', 'null'],
type: 'string',
nullable: true,
},
state: {
type: 'string',
@@ -626,12 +659,8 @@ export default {
target: {
type: 'string',
},
presence: {
type: 'string',
enum: ['optional', 'required'],
},
},
required: ['type', 'target', 'presence'],
required: ['type', 'target'],
additionalProperties: false,
},
SerializedError: {
@@ -995,11 +1024,14 @@ export default {
$ref: '#/components/parameters/limit',
},
{
$ref: '#/components/parameters/sortField',
$ref: '#/components/parameters/orderField',
},
{
$ref: '#/components/parameters/cursor',
},
{
$ref: '#/components/parameters/filter',
},
{
name: 'fullTextFilterTerm',
in: 'query',
@@ -1118,10 +1150,6 @@ export default {
schema: {
type: 'object',
properties: {
presence: {
type: 'string',
enum: ['required', 'optional'],
},
target: {
type: 'string',
},
@@ -1129,7 +1157,7 @@ export default {
type: 'string',
},
},
required: ['presence', 'target', 'type'],
required: ['target', 'type'],
},
},
},
@@ -1328,7 +1356,8 @@ export default {
type: 'string',
},
entity: {
$ref: '#/components/schemas/Entity',
type: 'object',
additionalProperties: true,
},
},
required: ['location', 'entity'],
@@ -1340,3 +1369,6 @@ export default {
},
},
} as const;
export const createOpenApiRouter = async (
options?: Parameters<typeof createValidatedOpenApiRouter>['1'],
) => createValidatedOpenApiRouter<typeof spec>(spec, options);
+48 -34
View File
@@ -1,4 +1,4 @@
openapi: 3.1.0
openapi: 3.0.3
info:
title: '@backstage/plugin-catalog-backend'
@@ -61,15 +61,20 @@ components:
in: query
description: Restrict to just these fields in the response.
required: false
allowReserved: true
schema:
type: string
type: array
items:
type: string
filter:
name: filter
in: query
description: Filter for just the entities defined by this filter.
required: false
schema:
type: string
type: array
items:
type: string
offset:
name: offset
in: query
@@ -86,17 +91,16 @@ components:
schema:
type: integer
minimum: 1
sortField:
name: sortField
orderField:
name: orderField
in: query
description: The fields to sort returned results by.
required: false
allowReserved: true
schema:
type: array
items:
type: array
items:
type: string
type: string
description: A two-item tuple of [field, order].
explode: true
style: form
@@ -208,7 +212,7 @@ components:
required:
- name
description: Metadata fields common to all versions/kinds of entity.
additionalProperties: false
additionalProperties: true
EntityRelation:
type: object
properties:
@@ -248,7 +252,34 @@ components:
- kind
- apiVersion
description: The parts of the format that's common to all versions/kinds of entity.
additionalProperties: false
additionalProperties: true
NullableEntity:
type: object
properties:
relations:
type: array
items:
$ref: '#/components/schemas/EntityRelation'
description: The relations that this entity has with other entities.
spec:
$ref: '#/components/schemas/JsonObject'
metadata:
$ref: '#/components/schemas/EntityMeta'
kind:
type: string
description: The high level entity type being described.
apiVersion:
type: string
description: |-
The version of specification format for this particular entity that
this is written against.
required:
- metadata
- kind
- apiVersion
description: The parts of the format that's common to all versions/kinds of entity.
additionalProperties: true
nullable: true
EntityAncestryResponse:
type: object
properties:
@@ -279,8 +310,7 @@ components:
type: array
items:
anyOf:
- $ref: '#/components/schemas/Entity'
- type: 'null'
- $ref: '#/components/schemas/NullableEntity'
description: |-
The list of entities, in the same order as the refs in the request. Entries
that are null signify that no entity existed with that ref.
@@ -326,11 +356,6 @@ components:
LocationSpec:
type: object
properties:
presence:
type: string
enum:
- optional
- required
target:
type: string
type:
@@ -478,9 +503,8 @@ components:
field empty; which would currently make it owned by X" where X is taken from the
codeowners file.
value:
type:
- string
- 'null'
type: string
nullable: true
state:
type: string
enum:
@@ -540,15 +564,9 @@ components:
type: string
target:
type: string
presence:
type: string
enum:
- optional
- required
required:
- type
- target
- presence
additionalProperties: false
SerializedError:
allOf:
@@ -763,8 +781,9 @@ paths:
parameters:
- $ref: '#/components/parameters/fields'
- $ref: '#/components/parameters/limit'
- $ref: '#/components/parameters/sortField'
- $ref: '#/components/parameters/orderField'
- $ref: '#/components/parameters/cursor'
- $ref: '#/components/parameters/filter'
- name: fullTextFilterTerm
in: query
description: Text search term.
@@ -841,17 +860,11 @@ paths:
schema:
type: object
properties:
presence:
type: string
enum:
- required
- optional
target:
type: string
type:
type: string
required:
- presence
- target
- type
get:
@@ -978,7 +991,8 @@ paths:
location:
type: string
entity:
$ref: '#/components/schemas/Entity'
type: object
additionalProperties: true
required:
- location
- entity
@@ -247,6 +247,24 @@ describe('createRouter readonly disabled', () => {
expect(response.status).toEqual(400);
expect(response.body.error.message).toMatch(/Malformed cursor/);
});
it('should throw in case of invalid limit', async () => {
const items: Entity[] = [
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
];
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items,
totalItems: 100,
pageInfo: { nextCursor: mockCursor() },
});
const response = await request(app).get(`/entities/by-query?limit=asdf`);
expect(response.status).toEqual(400);
expect(response.body.error.message).toMatch(
/request\/query\/limit must be integer/,
);
});
});
describe('GET /entities/by-uid/:uid', () => {
@@ -25,7 +25,6 @@ import {
import { Config } from '@backstage/config';
import { NotFoundError, serializeError } from '@backstage/errors';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import yn from 'yn';
import { z } from 'zod';
@@ -37,7 +36,6 @@ import {
basicEntityFilter,
entitiesBatchRequest,
parseEntityFilterParams,
parseEntityPaginationParams,
parseEntityTransformParams,
parseQueryEntitiesParams,
} from './request';
@@ -50,10 +48,10 @@ import {
locationInput,
validateRequestBody,
} from './util';
import type { ApiRouter } from '@backstage/backend-openapi-utils';
import spec from '../schema/openapi.generated';
import { createOpenApiRouter } from '../schema/openapi.generated';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
import { parseEntityPaginationParams } from './request/parseEntityPaginationParams';
/**
* Options used by {@link createRouter}.
@@ -80,6 +78,13 @@ export interface RouterOptions {
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = await createOpenApiRouter({
validatorOptions: {
// We want the spec to be up to date with the expected value, but the return type needs
// to be controlled by the router implementation not the request validator.
ignorePaths: /^\/validate-entity\/?$/,
},
});
const {
entitiesCatalog,
locationAnalyzer,
@@ -90,8 +95,6 @@ export async function createRouter(
logger,
permissionIntegrationRouter,
} = options;
const router = Router() as ApiRouter<typeof spec>;
router.use(express.json());
const readonlyEnabled =
config.getOptionalBoolean('catalog.readonly') || false;
@@ -142,6 +145,7 @@ export async function createRouter(
.get('/entities/by-query', async (req, res) => {
const { items, pageInfo, totalItems } =
await entitiesCatalog.queryEntities({
limit: req.query.limit,
...parseQueryEntitiesParams(req.query),
authorizationToken: getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
@@ -17,6 +17,5 @@
export { entitiesBatchRequest } from './entitiesBatchRequest';
export { basicEntityFilter } from './basicEntityFilter';
export { parseEntityFilterParams } from './parseEntityFilterParams';
export { parseEntityPaginationParams } from './parseEntityPaginationParams';
export { parseEntityTransformParams } from './parseEntityTransformParams';
export { parseQueryEntitiesParams } from './parseQueryEntitiesParams';
@@ -19,29 +19,23 @@ import { parseEntityPaginationParams } from './parseEntityPaginationParams';
describe('parseEntityPaginationParams', () => {
it('works for the happy path', () => {
expect(parseEntityPaginationParams({})).toBeUndefined();
expect(parseEntityPaginationParams({ limit: '1' })).toEqual({ limit: 1 });
expect(parseEntityPaginationParams({ offset: '0' })).toEqual({ offset: 0 });
expect(parseEntityPaginationParams({ offset: '2' })).toEqual({ offset: 2 });
expect(parseEntityPaginationParams({ limit: 1 })).toEqual({ limit: 1 });
expect(parseEntityPaginationParams({ offset: 0 })).toEqual({ offset: 0 });
expect(parseEntityPaginationParams({ offset: 2 })).toEqual({ offset: 2 });
expect(parseEntityPaginationParams({ after: 'x' })).toEqual({ after: 'x' });
expect(
parseEntityPaginationParams({ limit: '1', offset: '2', after: 'x' }),
parseEntityPaginationParams({ limit: 1, offset: 2, after: 'x' }),
).toEqual({ limit: 1, offset: 2, after: 'x' });
});
it('rejects bad values', () => {
expect(() => parseEntityPaginationParams({ limit: '' })).toThrow(
'Invalid limit, not an integer',
);
expect(() => parseEntityPaginationParams({ limit: '0' })).toThrow(
expect(() => parseEntityPaginationParams({ limit: 0 })).toThrow(
'Invalid limit, must be greater than zero',
);
expect(() => parseEntityPaginationParams({ limit: '-1' })).toThrow(
expect(() => parseEntityPaginationParams({ limit: -1 })).toThrow(
'Invalid limit, must be greater than zero',
);
expect(() => parseEntityPaginationParams({ offset: '' })).toThrow(
'Invalid offset, not an integer',
);
expect(() => parseEntityPaginationParams({ offset: '-1' })).toThrow(
expect(() => parseEntityPaginationParams({ offset: -1 })).toThrow(
'Invalid offset, must be zero or greater',
);
expect(() => parseEntityPaginationParams({ after: '' })).toThrow(
@@ -16,19 +16,20 @@
import { InputError } from '@backstage/errors';
import { EntityPagination } from '../../catalog/types';
import { parseIntegerParam, parseStringParam } from './common';
/**
* Parses the pagination related parameters out of a query, e.g.
* /entities?offset=100&limit=10
*/
export function parseEntityPaginationParams(
params: Record<string, unknown>,
): EntityPagination | undefined {
const offset = parseIntegerParam(params.offset, 'offset');
const limit = parseIntegerParam(params.limit, 'limit');
const after = parseStringParam(params.after, 'after');
export function parseEntityPaginationParams({
limit,
offset,
after,
}: {
offset?: number;
limit?: number;
after?: string;
}): EntityPagination | undefined {
if (offset === undefined && limit === undefined && after === undefined) {
return undefined;
}
@@ -37,7 +37,6 @@ describe('parseQueryEntitiesParams', () => {
const parsedObj = parseQueryEntitiesParams(
validRequest,
) as QueryEntitiesInitialRequest;
expect(parsedObj.limit).toBe(3);
expect(parsedObj.fields).toBeDefined();
expect(parsedObj.orderFields).toEqual([
{ field: 'metadata.name', order: 'desc' },
@@ -54,7 +53,6 @@ describe('parseQueryEntitiesParams', () => {
const parsedObj = parseQueryEntitiesParams(
{},
) as QueryEntitiesInitialRequest;
expect(parsedObj.limit).toBeUndefined();
expect(parsedObj.fields).toBeUndefined();
expect(parsedObj.orderFields).toBeUndefined();
expect(parsedObj.filter).toBeUndefined();
@@ -64,9 +62,6 @@ describe('parseQueryEntitiesParams', () => {
});
it.each([
{
limit: 'asd',
},
{ filter: 3 },
{ orderField: ['metadata.uid,diagonal'] },
{ fields: [4] },
@@ -94,7 +89,6 @@ describe('parseQueryEntitiesParams', () => {
const parsedObj = parseQueryEntitiesParams(
validRequest,
) as QueryEntitiesCursorRequest;
expect(parsedObj.limit).toBe(3);
expect(parsedObj.fields).toBeDefined();
expect(parsedObj.cursor).toEqual(cursor);
});
@@ -118,7 +112,6 @@ describe('parseQueryEntitiesParams', () => {
const parsedObj = parseQueryEntitiesParams(
validRequest,
) as QueryEntitiesCursorRequest;
expect(parsedObj.limit).toBe(3);
expect(parsedObj.fields).toBeDefined();
expect(parsedObj.cursor).toEqual(cursor);
expect(parsedObj).not.toHaveProperty('filter');
@@ -130,18 +123,14 @@ describe('parseQueryEntitiesParams', () => {
const parsedObj = parseQueryEntitiesParams(
{},
) as QueryEntitiesCursorRequest;
expect(parsedObj.limit).toBeUndefined();
expect(parsedObj.fields).toBeUndefined();
});
it.each([
{
limit: 'asd',
it.each([{ cursor: [] }, { fields: [4] }])(
'should throw if some parameter is not valid %p',
params => {
expect(() => parseQueryEntitiesParams(params)).toThrow();
},
{ cursor: [] },
{ fields: [4] },
])('should throw if some parameter is not valid %p', params => {
expect(() => parseQueryEntitiesParams(params)).toThrow();
});
);
});
});
@@ -20,7 +20,7 @@ import {
QueryEntitiesRequest,
} from '../../catalog/types';
import { decodeCursor } from '../util';
import { parseIntegerParam, parseStringParam } from './common';
import { parseStringParam } from './common';
import { parseEntityFilterParams } from './parseEntityFilterParams';
import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams';
import { parseEntityTransformParams } from './parseEntityTransformParams';
@@ -28,16 +28,14 @@ import { parseFullTextFilterFields } from './parseFullTextFilterFields';
export function parseQueryEntitiesParams(
params: Record<string, unknown>,
): Omit<QueryEntitiesRequest, 'authorizationToken'> {
): Omit<QueryEntitiesRequest, 'authorizationToken' | 'limit'> {
const fields = parseEntityTransformParams(params);
const limit = parseIntegerParam(params.limit, 'limit');
const cursor = parseStringParam(params.cursor, 'cursor');
if (cursor) {
const decodedCursor = decodeCursor(cursor);
const response: Omit<QueryEntitiesCursorRequest, 'authorizationToken'> = {
cursor: decodedCursor,
fields,
limit,
};
return response;
}
@@ -54,7 +52,6 @@ export function parseQueryEntitiesParams(
const response: Omit<QueryEntitiesInitialRequest, 'authorizationToken'> = {
fields,
filter,
limit,
orderFields,
fullTextFilter: {
term: fullTextFilterTerm || '',
+1 -1
View File
@@ -37,6 +37,7 @@
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-openapi-utils": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
@@ -57,7 +58,6 @@
"zod": "^3.21.4"
},
"devDependencies": {
"@backstage/backend-openapi-utils": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/supertest": "^2.0.8",
@@ -17,8 +17,9 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { createValidatedOpenApiRouter } from '@backstage/backend-openapi-utils';
export default {
export const spec = {
openapi: '3.0.3',
info: {
title: '@backstage/plugin-search-backend',
@@ -210,3 +211,6 @@ export default {
},
},
} as const;
export const createOpenApiRouter = async (
options?: Parameters<typeof createValidatedOpenApiRouter>['1'],
) => createValidatedOpenApiRouter<typeof spec>(spec, options);
@@ -21,7 +21,6 @@ import { IndexBuilder } from '@backstage/plugin-search-backend-node';
import { SearchEngine } from '@backstage/plugin-search-common';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
const mockPermissionEvaluator: PermissionEvaluator = {
+3 -12
View File
@@ -15,7 +15,6 @@
*/
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { z } from 'zod';
import { errorHandler } from '@backstage/backend-common';
@@ -35,8 +34,7 @@ import {
} from '@backstage/plugin-search-common';
import { SearchEngine } from '@backstage/plugin-search-common';
import { AuthorizedSearchEngine } from './AuthorizedSearchEngine';
import type { ApiRouter } from '@backstage/backend-openapi-utils';
import spec from '../schema/openapi.generated';
import { createOpenApiRouter } from '../schema/openapi.generated';
const jsonObjectSchema: z.ZodSchema<JsonObject> = z.lazy(() => {
const jsonValueSchema: z.ZodSchema<JsonValue> = z.lazy(() =>
@@ -73,6 +71,7 @@ const allowedLocationProtocols = ['http:', 'https:'];
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = await createOpenApiRouter();
const { engine: inputEngine, types, permissions, config, logger } = options;
const maxPageLimit =
@@ -86,14 +85,7 @@ export async function createRouter(
.optional(),
pageCursor: z.string().optional(),
pageLimit: z
.string()
.transform(pageLimit => parseInt(pageLimit, 10))
.refine(
pageLimit => !isNaN(pageLimit),
pageLimit => ({
message: `The page limit "${pageLimit}" is not a number`,
}),
)
.number()
.refine(
pageLimit => pageLimit <= maxPageLimit,
pageLimit => ({
@@ -148,7 +140,6 @@ export async function createRouter(
})),
});
const router = Router() as ApiRouter<typeof spec>;
router.get('/query', async (req, res) => {
const parseResult = requestSchema.passthrough().safeParse(req.query);
+1 -1
View File
@@ -30,6 +30,7 @@
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-openapi-utils": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/catalog-model": "workspace:^",
@@ -45,7 +46,6 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/backend-openapi-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/supertest": "^2.0.8",
"msw": "^1.0.0",
-17
View File
@@ -16,23 +16,6 @@
import { InputError } from '@backstage/errors';
export const parseIntegerParam = (
str: unknown,
ctx: string,
): number | undefined => {
if (str === undefined) {
return undefined;
}
if (typeof str !== 'string') {
throw new InputError(`invalid ${ctx}, must be a string`);
}
const parsed = parseInt(str, 10);
if (!Number.isInteger(parsed) || String(parsed) !== str) {
throw new InputError(`invalid ${ctx}, not an integer`);
}
return parsed;
};
export const parseOrderByParam = <T extends readonly string[]>(
str: unknown,
allowedFields: T,
@@ -17,8 +17,9 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { createValidatedOpenApiRouter } from '@backstage/backend-openapi-utils';
export default {
export const spec = {
openapi: '3.0.3',
info: {
title: '@backstage/plugin-todo-backend',
@@ -149,7 +150,8 @@ export default {
{
name: 'entity',
in: 'query',
required: true,
required: false,
allowReserved: true,
schema: {
type: 'string',
minLength: 1,
@@ -206,3 +208,6 @@ export default {
},
},
} as const;
export const createOpenApiRouter = async (
options?: Parameters<typeof createValidatedOpenApiRouter>['1'],
) => createValidatedOpenApiRouter<typeof spec>(spec, options);
+2 -1
View File
@@ -95,7 +95,8 @@ paths:
parameters:
- name: entity
in: query
required: true
required: false
allowReserved: true
schema:
type: string
minLength: 1
+31 -49
View File
@@ -19,11 +19,7 @@ import request from 'supertest';
import { errorHandler } from '@backstage/backend-common';
import { createRouter } from './router';
import {
parseFilterParam,
parseIntegerParam,
parseOrderByParam,
} from '../lib/utils';
import { parseFilterParam, parseOrderByParam } from '../lib/utils';
import { TodoService } from './types';
const mockListBody = {
@@ -136,58 +132,44 @@ describe('createRouter', () => {
});
it('rejects invalid queries', async () => {
await expect(
request(app).get('/v1/todos?entity=k:n&entity=k:n'),
).resolves.toMatchObject(
matchErrorResponse(400, 'InputError', 'entity query must be a string'),
);
request(app)
.get('/v1/todos?entity=k:n&entity=k:n')
.expect(400)
.expect(
matchErrorResponse(
400,
'InputError',
'entity query must be a string',
),
);
await expect(
request(app).get('/v1/todos?entity=:n'),
).resolves.toMatchObject(
matchErrorResponse(
400,
'InputError',
'Invalid entity ref, TypeError: Entity reference ":n" was not on the form [<kind>:][<namespace>/]<name>',
),
);
request(app)
.get('/v1/todos?entity=:n')
.expect(400)
.expect(
matchErrorResponse(
400,
'InputError',
'Invalid entity ref, TypeError: Entity reference ":n" was not on the form [<kind>:][<namespace>/]<name>',
),
);
await expect(
request(app).get('/v1/todos?offset=1.5'),
).resolves.toMatchObject(
matchErrorResponse(
400,
'InputError',
'invalid offset query, not an integer',
),
);
request(app)
.get('/v1/todos?offset=1.5')
.expect(400)
.expect(
matchErrorResponse(
400,
'InputError',
'invalid offset query, not an integer',
),
);
expect(mockService.listTodos).not.toHaveBeenCalled();
});
});
});
describe('parseIntegerParam', () => {
it('should parse a param', () => {
expect(parseIntegerParam('1', 'ctx')).toBe(1);
});
it('should reject invalid params', () => {
expect(() => parseIntegerParam(['1'], 'ctx')).toThrow(
'invalid ctx, must be a string',
);
expect(() => parseIntegerParam('1.5', 'ctx')).toThrow(
'invalid ctx, not an integer',
);
expect(() => parseIntegerParam('foo', 'ctx')).toThrow(
'invalid ctx, not an integer',
);
expect(() => parseIntegerParam('1foo', 'ctx')).toThrow(
'invalid ctx, not an integer',
);
});
});
describe('parseOrderByParam', () => {
it('should parse a param', () => {
expect(parseOrderByParam('a=asc', ['a'])).toEqual({
+4 -9
View File
@@ -17,16 +17,13 @@
import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model';
import { InputError } from '@backstage/errors';
import express from 'express';
import Router from 'express-promise-router';
import { type TodoService, TODO_FIELDS } from './types';
import {
getBearerToken,
parseFilterParam,
parseIntegerParam,
parseOrderByParam,
} from '../lib/utils';
import spec from '../schema/openapi.generated';
import type { ApiRouter } from '@backstage/backend-openapi-utils';
import { createOpenApiRouter } from '../schema/openapi.generated';
/** @public */
export interface RouterOptions {
@@ -37,14 +34,12 @@ export interface RouterOptions {
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = await createOpenApiRouter();
router.use(express.json());
const { todoService } = options;
const router = Router() as ApiRouter<typeof spec>;
router.use(express.json());
router.get('/v1/todos', async (req, res) => {
const offset = parseIntegerParam(req.query.offset, 'offset query');
const limit = parseIntegerParam(req.query.limit, 'limit query');
const { offset, limit } = req.query;
const orderBy = parseOrderByParam(req.query.orderBy, TODO_FIELDS);
const filters = parseFilterParam(req.query.filter, TODO_FIELDS);