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
@@ -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);