Update catalog-backend to use custom router.

Signed-off-by: Aramis Sennyey <sennyeya@amazon.com>
This commit is contained in:
Aramis Sennyey
2023-02-16 16:32:53 -05:00
committed by Fredrik Adelöw
parent e1962a4f0a
commit eaefab31c6
10 changed files with 1298 additions and 180 deletions
+1
View File
@@ -54,6 +54,7 @@
"@backstage/integration": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-openapi-router": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/plugin-scaffolder-common": "workspace:^",
@@ -23,9 +23,8 @@ import {
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { InputError, NotFoundError, serializeError } from '@backstage/errors';
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';
@@ -50,16 +49,8 @@ import {
locationInput,
validateRequestBody,
} from './util';
import { initialize } from 'express-openapi';
import yaml from 'js-yaml';
import fs from 'fs';
import path from 'path';
class ParsingError extends Error {
toString() {
return `ParsingError: ${this.message}`;
}
}
import { ApiRouter, DeepWriteable } from '@backstage/plugin-openapi-router';
import spec from './schema/openapi';
/**
* Options used by {@link createRouter}.
@@ -95,7 +86,9 @@ export async function createRouter(
logger,
permissionIntegrationRouter,
} = options;
const router = Router();
const router = new ApiRouter<DeepWriteable<typeof spec>>(
spec as DeepWriteable<typeof spec>,
);
router.use(express.json());
const readonlyEnabled =
@@ -104,42 +97,26 @@ export async function createRouter(
logger.info('Catalog is running in readonly mode');
}
const validateDependency = (
dependency: any,
next: (req: express.Request, res: express.Response) => any,
) => {
return (req: express.Request, res: express.Response) => {
if (!dependency) {
console.log('no dependency');
throw new NotFoundError(
'Dependency Error',
'Dependency not set up for this endpoint.',
);
}
return next(req, res);
};
};
if (refreshService) {
router.post('/refresh', async (req, res) => {
const refreshOptions: RefreshOptions = req.body;
refreshOptions.authorizationToken = getBearerToken(
req.header('authorization'),
);
initialize({
app: router as any,
// NOTE: If using yaml you can provide a path relative to process.cwd() e.g.
// apiDoc: './api-v1/api-doc.yml',
apiDoc: yaml.load(
// eslint-disable-next-line no-restricted-syntax
fs.readFileSync(path.resolve(__dirname, '../../openapi.yaml'), 'utf-8'),
) as any,
operations: {
RefreshEntity: validateDependency(refreshService, async (req, res) => {
const refreshOptions: RefreshOptions = req.body;
refreshOptions.authorizationToken = getBearerToken(
req.header('authorization'),
);
await refreshService.refresh(refreshOptions);
res.status(200).end();
});
}
await refreshService!.refresh(refreshOptions);
res.status(200).end();
}),
GetEntities: validateDependency(entitiesCatalog, async (req, res) => {
const { entities, pageInfo } = await entitiesCatalog!.entities({
if (permissionIntegrationRouter) {
router.use(permissionIntegrationRouter);
}
if (entitiesCatalog) {
router
.get('/entities', async (req, res) => {
const { entities, pageInfo } = await entitiesCatalog.entities({
filter: parseEntityFilterParams(req.query),
fields: parseEntityTransformParams(req.query),
order: parseEntityOrderParams(req.query),
@@ -157,10 +134,10 @@ export async function createRouter(
// TODO(freben): encode the pageInfo in the response
res.json(entities);
}),
GetEntityByUid: validateDependency(entitiesCatalog, async (req, res) => {
})
.get('/entities/by-uid/:uid', async (req, res) => {
const { uid } = req.params;
const { entities } = await entitiesCatalog!.entities({
const { entities } = await entitiesCatalog.entities({
filter: basicEntityFilter({ 'metadata.uid': uid }),
authorizationToken: getBearerToken(req.header('authorization')),
});
@@ -168,10 +145,17 @@ export async function createRouter(
throw new NotFoundError(`No entity with uid ${uid}`);
}
res.status(200).json(entities[0]);
}),
GetEntityByName: validateDependency(entitiesCatalog, async (req, res) => {
})
.delete('/entities/by-uid/:uid', async (req, res) => {
const { uid } = req.params;
await entitiesCatalog.removeEntityByUid(uid, {
authorizationToken: getBearerToken(req.header('authorization')),
});
res.status(204).end();
})
.get('/entities/by-name/:kind/:namespace/:name', async (req, res) => {
const { kind, namespace, name } = req.params;
const { entities } = await entitiesCatalog!.entities({
const { entities } = await entitiesCatalog.entities({
filter: basicEntityFilter({
kind: kind,
'metadata.namespace': namespace,
@@ -185,27 +169,41 @@ export async function createRouter(
);
}
res.status(200).json(entities[0]);
}),
GetEntityAncestryByName: validateDependency(
entitiesCatalog,
})
.get(
'/entities/by-name/:kind/:namespace/:name/ancestry',
async (req, res) => {
const { kind, namespace, name } = req.params;
const entityRef = stringifyEntityRef({ kind, namespace, name });
const response = await entitiesCatalog!.entityAncestry(entityRef, {
const response = await entitiesCatalog.entityAncestry(entityRef, {
authorizationToken: getBearerToken(req.header('authorization')),
});
res.status(200).json(response);
},
),
GetEntityFacets: validateDependency(entitiesCatalog, async (req, res) => {
const response = await entitiesCatalog!.facets({
)
.post('/entities/by-refs', async (req, res) => {
const request = entitiesBatchRequest(req);
const token = getBearerToken(req.header('authorization'));
const response = await entitiesCatalog.entitiesBatch({
entityRefs: request.entityRefs,
fields: parseEntityTransformParams(req.query, request.fields),
authorizationToken: token,
});
res.status(200).json(response);
})
.get('/entity-facets', async (req, res) => {
const response = await entitiesCatalog.facets({
filter: parseEntityFilterParams(req.query),
facets: parseEntityFacetParams(req.query),
authorizationToken: getBearerToken(req.header('authorization')),
});
res.status(200).json(response);
}),
CreateLocation: validateDependency(locationService, async (req, res) => {
});
}
if (locationService) {
router
.post('/locations', async (req, res) => {
const location = await validateRequestBody(req, locationInput);
const dryRun = yn(req.query.dryRun, { default: false });
@@ -219,21 +217,22 @@ export async function createRouter(
authorizationToken: getBearerToken(req.header('authorization')),
});
res.status(201).json(output);
}),
GetLocations: validateDependency(locationService, async (req, res) => {
})
.get('/locations', async (req, res) => {
const locations = await locationService.listLocations({
authorizationToken: getBearerToken(req.header('authorization')),
});
res.status(200).json(locations.map(l => ({ data: l })));
}),
GetLocation: validateDependency(locationService, async (req, res) => {
})
.get('/locations/:id', async (req, res) => {
const { id } = req.params;
const output = await locationService.getLocation(id, {
authorizationToken: getBearerToken(req.header('authorization')),
});
res.status(200).json(output);
}),
DeleteLocation: validateDependency(locationService, async (req, res) => {
})
.delete('/locations/:id', async (req, res) => {
disallowReadonlyMode(readonlyEnabled);
const { id } = req.params;
@@ -241,115 +240,75 @@ export async function createRouter(
authorizationToken: getBearerToken(req.header('authorization')),
});
res.status(204).end();
}),
AnalyzeLocation: validateDependency(locationService, async (req, res) => {
const body = await validateRequestBody(
req,
z.object({
location: locationInput,
catalogFilename: z.string().optional(),
}),
);
const schema = z.object({
});
}
if (locationAnalyzer) {
router.post('/analyze-location', async (req, res) => {
const body = await validateRequestBody(
req,
z.object({
location: locationInput,
catalogFilename: z.string().optional(),
});
const output = await locationAnalyzer!.analyzeLocation(
schema.parse(body),
);
res.status(200).json(output);
}),
ValidateEntity: validateDependency(orchestrator, async (req, res) => {
const bodySchema = z.object({
entity: z.unknown(),
location: z.string(),
});
}),
);
const schema = z.object({
location: locationInput,
catalogFilename: z.string().optional(),
});
const output = await locationAnalyzer.analyzeLocation(schema.parse(body));
res.status(200).json(output);
});
}
let body: z.infer<typeof bodySchema>;
let entity: Entity;
let location: { type: string; target: string };
try {
body = await validateRequestBody(req, bodySchema);
entity = validateEntityEnvelope(body.entity);
location = parseLocationRef(body.location);
if (location.type !== 'url')
throw new TypeError(
`Invalid location ref ${body.location}, only 'url:<target>' is supported, e.g. url:https://host/path`,
);
} catch (err) {
return res.status(400).json({
errors: [serializeError(err)],
});
}
if (orchestrator) {
router.post('/validate-entity', async (req, res) => {
const bodySchema = z.object({
entity: z.unknown(),
location: z.string(),
});
const processingResult = await orchestrator!.process({
entity: {
...entity,
metadata: {
...entity.metadata,
annotations: {
[ANNOTATION_LOCATION]: body.location,
[ANNOTATION_ORIGIN_LOCATION]: body.location,
...entity.metadata.annotations,
},
let body: z.infer<typeof bodySchema>;
let entity: Entity;
let location: { type: string; target: string };
try {
body = await validateRequestBody(req, bodySchema);
entity = validateEntityEnvelope(body.entity);
location = parseLocationRef(body.location);
if (location.type !== 'url')
throw new TypeError(
`Invalid location ref ${body.location}, only 'url:<target>' is supported, e.g. url:https://host/path`,
);
} catch (err) {
return res.status(400).json({
errors: [serializeError(err)],
});
}
const processingResult = await orchestrator.process({
entity: {
...entity,
metadata: {
...entity.metadata,
annotations: {
[ANNOTATION_LOCATION]: body.location,
[ANNOTATION_ORIGIN_LOCATION]: body.location,
...entity.metadata.annotations,
},
},
},
});
if (!processingResult.ok)
res.status(400).json({
errors: processingResult.errors.map(e => serializeError(e)),
});
if (!processingResult.ok)
res.status(400).json({
errors: processingResult.errors.map(e => serializeError(e)),
});
return res.status(200).end();
}),
DeleteEntityByUid: validateDependency(
entitiesCatalog,
async (req, res) => {
const { uid } = req.params;
await entitiesCatalog!.removeEntityByUid(uid, {
authorizationToken: getBearerToken(req.header('authorization')),
});
res.status(204).end();
},
),
GetEntitiesByRefs: validateDependency(
entitiesCatalog,
async (req, res) => {
const request = entitiesBatchRequest(req);
const token = getBearerToken(req.header('authorization'));
const response = await entitiesCatalog!.entitiesBatch({
entityRefs: request.entityRefs,
fields: parseEntityTransformParams(req.query, request.fields),
authorizationToken: token,
});
res.status(200).json(response);
},
),
},
enableObjectCoercion: true,
errorMiddleware: errorHandler(),
errorTransformer: openapiError => {
const error = openapiError as {
errorCode: string;
path: string;
message: string;
};
// eslint-disable-next-line default-case
switch (error.errorCode) {
case 'type.openapi.requestValidation':
throw new InputError(
`Invalid field ${error.path}`,
new ParsingError(error.message),
);
}
return {};
},
});
if (permissionIntegrationRouter) {
router.use(permissionIntegrationRouter);
return res.status(200).end();
});
}
return router;
router.use(errorHandler());
return router.build();
}
function getBearerToken(
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -34,6 +34,8 @@
"json-schema-to-ts": "^2.6.2",
"openapi-types": "^12.1.0",
"openapi3-ts": "^3.1.2",
"ts-node": "^10.9.1"
"ts-node": "^10.9.1",
"winston": "^3.8.2",
"yn": "^5.0.0"
}
}
+17 -8
View File
@@ -14,11 +14,10 @@
* limitations under the License.
*/
import { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
import { IRouter, Router } from 'express';
import { Router } from 'express';
import core, { ParamsDictionary } from 'express-serve-static-core';
import { FromSchema, JSONSchema7 } from 'json-schema-to-ts';
import {
DocPath,
DocPathMethod,
DocPathTemplate,
MethodAwareDocPath,
@@ -29,7 +28,9 @@ import {
} from './types';
import { ResponseSchemas } from './types/response';
type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };
export type DeepWriteable<T> = {
-readonly [P in keyof T]: DeepWriteable<T[P]>;
};
const doc = {
openapi: '3.1.0',
@@ -253,7 +254,7 @@ type ConvertAll<T, R extends ReadonlyArray<unknown> = []> = T extends [
? ConvertAll<Rest, [...R, FromSchema<First>]>
: R;
type ResponseToJsonSchema<
type ResponseBodyToJsonSchema<
Doc extends RequiredDoc,
Path extends PathTemplate<Extract<keyof Doc['paths'], string>>,
Method extends DocPathMethod<Doc, Path>,
@@ -261,6 +262,14 @@ type ResponseToJsonSchema<
TuplifyUnion<ValueOf<ResponseSchemas<Doc, Path, Method>>>
>[number];
type RequestBodyToJsonSchema<
Doc extends RequiredDoc,
Path extends PathTemplate<Extract<keyof Doc['paths'], string>>,
Method extends DocPathMethod<Doc, Path>,
> = ConvertAll<
TuplifyUnion<ValueOf<RequestBodySchema<Doc, Path, Method>>>
>[number];
type DocRequestHandler<
Doc extends RequiredDoc,
Path extends DocPathTemplate<Doc>,
@@ -268,13 +277,13 @@ type DocRequestHandler<
> = core.RequestHandler<
core.ParamsDictionary,
// From https://stackoverflow.com/questions/71393738/typescript-intersection-not-union-type-from-json-schema.
ResponseToJsonSchema<Doc, Path, Method>,
RequestBodySchema<Doc, Path, Method>,
ResponseBodyToJsonSchema<Doc, Path, Method>,
RequestBodyToJsonSchema<Doc, Path, Method>,
ParsedQs,
Record<string, string>
>;
export default class ApiRouter<Doc extends RequiredDoc> {
export class ApiRouter<Doc extends RequiredDoc> {
private _router = Router();
constructor(private spec: OpenAPIV3_1.Document | OpenAPIV3.Document) {}
@@ -372,7 +381,7 @@ export async function createRouter(options: RouterOptions) {
router.get('/pets/:uid', (req, res) => {
res.json({
id: 1,
name: req.params['uid'],
name: req.params.uid,
});
});
@@ -1,3 +1,19 @@
/*
* Copyright 2023 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.
*/
/**
* Pulled from https://github.com/varanauskas/oatx.
*/
@@ -1,2 +1,17 @@
/*
* Copyright 2023 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.
*/
export * from './common';
export * from './requests';
@@ -1,6 +1,23 @@
/*
* Copyright 2023 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.
*/
/**
* Pulled from https://github.com/varanauskas/oatx.
*/
import type { ReferenceObject, RequestBodyObject } from 'openapi3-ts';
import type {
ComponentRef,
@@ -1,6 +1,23 @@
/*
* Copyright 2023 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.
*/
/**
* Pulled from https://github.com/varanauskas/oatx.
*/
import type { ReferenceObject, ResponseObject } from 'openapi3-ts';
import type {
ComponentRef,
+5 -2
View File
@@ -5353,6 +5353,7 @@ __metadata:
"@backstage/integration": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
"@backstage/plugin-openapi-router": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
"@backstage/plugin-scaffolder-common": "workspace:^"
@@ -7446,7 +7447,7 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-openapi-router@workspace:plugins/openapi-router-common":
"@backstage/plugin-openapi-router@workspace:^, @backstage/plugin-openapi-router@workspace:plugins/openapi-router-common":
version: 0.0.0-use.local
resolution: "@backstage/plugin-openapi-router@workspace:plugins/openapi-router-common"
dependencies:
@@ -7456,6 +7457,8 @@ __metadata:
openapi-types: ^12.1.0
openapi3-ts: ^3.1.2
ts-node: ^10.9.1
winston: ^3.8.2
yn: ^5.0.0
languageName: unknown
linkType: soft
@@ -40279,7 +40282,7 @@ __metadata:
languageName: node
linkType: hard
"winston@npm:^3.2.1":
"winston@npm:^3.2.1, winston@npm:^3.8.2":
version: 3.8.2
resolution: "winston@npm:3.8.2"
dependencies: