merge master

Signed-off-by: mclarke <mclarke@spotify.com>
This commit is contained in:
mclarke
2021-03-11 16:13:24 +00:00
98 changed files with 1128 additions and 193 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': patch
---
Throw the new `ResponseError` from `@backstage/errors`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-graphiql': patch
---
Export `GraphiQLIcon`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-user-settings': patch
---
Avoid using `ApiRef` descriptions in the UI.
+31
View File
@@ -0,0 +1,31 @@
---
'@backstage/backend-common': minor
---
Encode thrown errors in the backend as a JSON payload. This is technically a breaking change, since the response format even of errors are part of the contract. If you relied on the response being text, you will now have some extra JSON "noise" in it. It should still be readable by end users though.
Before:
```
NotFoundError: No entity named 'tara.macgovern2' found, with kind 'user' in namespace 'default'
at eval (webpack-internal:///../../plugins/catalog-backend/src/service/router.ts:117:17)
```
After:
```json
{
"error": {
"name": "NotFoundError",
"message": "No entity named 'tara.macgovern2' found, with kind 'user' in namespace 'default'",
"stack": "NotFoundError: No entity named 'tara.macgovern2' found, with kind 'user' in namespace 'default'\n at eval (webpack-internal:///../../plugins/catalog-backend/src/service/router.ts:117:17)"
},
"request": {
"method": "GET",
"url": "/entities/by-name/user/default/tara.macgovern2"
},
"response": {
"statusCode": 404
}
}
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Lint storybook files, i.e. `*.stories.*`, as if they were tests.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
Add a `ResponseErrorPanel` to render `ResponseError` from `@backstage/errors`
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/techdocs-common': patch
'@backstage/plugin-auth-backend': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-backend': patch
'@backstage/plugin-scaffolder-backend': patch
'@backstage/plugin-techdocs-backend': patch
---
Use errors from `@backstage/errors`
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/backend-common': minor
---
Removed the custom error types (e.g. `NotFoundError`). Those are now instead in the new `@backstage/errors` package. This is a breaking change, and you will have to update your imports if you were using these types.
```diff
-import { NotFoundError } from '@backstage/backend-common';
+import { NotFoundError } from '@backstage/errors';
```
+2 -6
View File
@@ -36,7 +36,7 @@ import {
SidebarSpace,
} from '@backstage/core';
import { NavLink } from 'react-router-dom';
import { graphiQLRouteRef } from '@backstage/plugin-graphiql';
import { GraphiQLIcon } from '@backstage/plugin-graphiql';
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
import { SidebarSearch } from '@backstage/plugin-search';
@@ -90,11 +90,7 @@ const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
<SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
<SidebarItem
icon={graphiQLRouteRef.icon!}
to={graphiQLRouteRef.path}
text={graphiQLRouteRef.title}
/>
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
<SidebarSpace />
<SidebarDivider />
<SidebarSettings />
+1
View File
@@ -32,6 +32,7 @@
"@backstage/cli-common": "^0.1.1",
"@backstage/config": "^0.1.2",
"@backstage/config-loader": "^0.5.1",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.1",
"@octokit/rest": "^18.0.12",
"@types/cors": "^2.8.6",
-1
View File
@@ -17,7 +17,6 @@
export * from './config';
export * from './database';
export * from './discovery';
export * from './errors';
export * from './hot';
export * from './logging';
export * from './middleware';
@@ -14,10 +14,17 @@
* limitations under the License.
*/
import {
AuthenticationError,
ConflictError,
InputError,
NotAllowedError,
NotFoundError,
NotModifiedError,
} from '@backstage/errors';
import express from 'express';
import createError from 'http-errors';
import request from 'supertest';
import * as errors from '../errors';
import { errorHandler } from './errorHandler';
describe('errorHandler', () => {
@@ -31,17 +38,24 @@ describe('errorHandler', () => {
const response = await request(app).get('/breaks');
expect(response.status).toBe(500);
expect(response.text).toBe('some message');
expect(response.body).toEqual({
error: expect.objectContaining({
name: 'Error',
message: 'some message',
}),
request: { method: 'GET', url: '/breaks' },
response: { statusCode: 500 },
});
});
it('doesnt try to send the response again if its already been sent', async () => {
it('does not try to send the response again if its already been sent', async () => {
const app = express();
const mockSend = jest.fn();
app.use('/works_with_async_fail', (_, res) => {
res.status(200).send('hello');
// mutate the response object to test the middlware.
// mutate the response object to test the middleware.
// it's hard to catch errors inside middleware from the outside.
// @ts-ignore
res.send = mockSend;
@@ -67,38 +81,64 @@ describe('errorHandler', () => {
const response = await request(app).get('/breaks');
expect(response.status).toBe(432);
expect(response.text).toContain('Some Message');
expect(response.body).toEqual({
error: {
expose: true,
name: 'BadRequestError',
message: 'Some Message',
status: 432,
statusCode: 432,
},
request: {
method: 'GET',
url: '/breaks',
},
response: { statusCode: 432 },
});
});
it('handles well-known error classes', async () => {
const app = express();
app.use('/NotModifiedError', () => {
throw new errors.NotModifiedError();
throw new NotModifiedError();
});
app.use('/InputError', () => {
throw new errors.InputError();
throw new InputError();
});
app.use('/AuthenticationError', () => {
throw new errors.AuthenticationError();
throw new AuthenticationError();
});
app.use('/NotAllowedError', () => {
throw new errors.NotAllowedError();
throw new NotAllowedError();
});
app.use('/NotFoundError', () => {
throw new errors.NotFoundError();
throw new NotFoundError();
});
app.use('/ConflictError', () => {
throw new errors.ConflictError();
throw new ConflictError();
});
app.use(errorHandler());
const r = request(app);
expect((await r.get('/NotModifiedError')).status).toBe(304);
expect((await r.get('/InputError')).status).toBe(400);
expect((await r.get('/InputError')).body.error.name).toBe('InputError');
expect((await r.get('/AuthenticationError')).status).toBe(401);
expect((await r.get('/AuthenticationError')).body.error.name).toBe(
'AuthenticationError',
);
expect((await r.get('/NotAllowedError')).status).toBe(403);
expect((await r.get('/NotAllowedError')).body.error.name).toBe(
'NotAllowedError',
);
expect((await r.get('/NotFoundError')).status).toBe(404);
expect((await r.get('/NotFoundError')).body.error.name).toBe(
'NotFoundError',
);
expect((await r.get('/ConflictError')).status).toBe(409);
expect((await r.get('/ConflictError')).body.error.name).toBe(
'ConflictError',
);
});
it('logs all 500 errors', async () => {
@@ -126,7 +166,7 @@ describe('errorHandler', () => {
mockLogger.child.mockImplementation(() => mockLogger as any);
app.use('/NotFound', () => {
throw new errors.NotFoundError();
throw new NotFoundError();
});
app.use(errorHandler({ logger: mockLogger as any }));
@@ -142,7 +182,7 @@ describe('errorHandler', () => {
mockLogger.child.mockImplementation(() => mockLogger as any);
app.use('/NotFound', () => {
throw new errors.NotFoundError();
throw new NotFoundError();
});
app.use(errorHandler({ logger: mockLogger as any, logClientErrors: true }));
@@ -14,9 +14,18 @@
* limitations under the License.
*/
import {
AuthenticationError,
ConflictError,
ErrorResponse,
InputError,
NotAllowedError,
NotFoundError,
NotModifiedError,
serializeError,
} from '@backstage/errors';
import { ErrorRequestHandler, NextFunction, Request, Response } from 'express';
import { Logger } from 'winston';
import * as errors from '../errors';
import { getRootLogger } from '../logging';
export type ErrorHandlerOptions = {
@@ -48,14 +57,13 @@ export type ErrorHandlerOptions = {
* This is commonly the very last middleware in the chain.
*
* Its primary purpose is not to do translation of business logic exceptions,
* but rather to be a gobal catch-all for uncaught "fatal" errors that are
* but rather to be a global catch-all for uncaught "fatal" errors that are
* expected to result in a 500 error. However, it also does handle some common
* error types (such as http-error exceptions) and returns the enclosed status
* code accordingly.
*
* @returns An Express error request handler
*/
export function errorHandler(
options: ErrorHandlerOptions = {},
): ErrorRequestHandler {
@@ -66,12 +74,12 @@ export function errorHandler(
type: 'errorHandler',
});
return (
error: Error,
_request: Request,
res: Response,
next: NextFunction,
) => {
return (error: Error, req: Request, res: Response, next: NextFunction) => {
const statusCode = getStatusCode(error);
if (options.logClientErrors || statusCode >= 500) {
logger.error(error);
}
if (res.headersSent) {
// If the headers have already been sent, do not send the response again
// as this will throw an error in the backend.
@@ -79,16 +87,13 @@ export function errorHandler(
return;
}
const status = getStatusCode(error);
const message = showStackTraces ? error.stack : error.message;
const body: ErrorResponse = {
error: serializeError(error, { includeStack: showStackTraces }),
request: { method: req.method, url: req.url },
response: { statusCode },
};
if (options.logClientErrors || status >= 500) {
logger.error(error);
}
res.status(status);
res.setHeader('content-type', 'text/plain');
res.send(message);
res.status(statusCode).json(body);
};
}
@@ -109,17 +114,17 @@ function getStatusCode(error: Error): number {
// Handle well-known error types
switch (error.name) {
case errors.NotModifiedError.name:
case NotModifiedError.name:
return 304;
case errors.InputError.name:
case InputError.name:
return 400;
case errors.AuthenticationError.name:
case AuthenticationError.name:
return 401;
case errors.NotAllowedError.name:
case NotAllowedError.name:
return 403;
case errors.NotFoundError.name:
case NotFoundError.name:
return 404;
case errors.ConflictError.name:
case ConflictError.name:
return 409;
default:
break;
@@ -26,7 +26,7 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import * as os from 'os';
import path from 'path';
import { NotModifiedError } from '../errors';
import { NotModifiedError } from '@backstage/errors';
import { getVoidLogger } from '../logging';
import { AzureUrlReader } from './AzureUrlReader';
import { ReadTreeResponseFactory } from './tree';
@@ -26,7 +26,7 @@ import fetch from 'cross-fetch';
import parseGitUrl from 'git-url-parse';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { NotFoundError, NotModifiedError } from '../errors';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
import { ReadTreeResponseFactory } from './tree';
import { stripFirstDirectoryFromPath } from './tree/util';
import {
@@ -26,7 +26,7 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import os from 'os';
import path from 'path';
import { NotModifiedError } from '../errors';
import { NotModifiedError } from '@backstage/errors';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { ReadTreeResponseFactory } from './tree';
@@ -26,7 +26,7 @@ import fetch from 'cross-fetch';
import parseGitUrl from 'git-url-parse';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { NotFoundError, NotModifiedError } from '../errors';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
import { ReadTreeResponseFactory } from './tree';
import { stripFirstDirectoryFromPath } from './tree/util';
import {
@@ -15,7 +15,7 @@
*/
import fetch from 'cross-fetch';
import { NotFoundError } from '../errors';
import { NotFoundError } from '@backstage/errors';
import {
ReaderFactory,
ReadTreeResponse,
@@ -27,7 +27,7 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import os from 'os';
import path from 'path';
import { NotFoundError, NotModifiedError } from '../errors';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
import {
GhBlobResponse,
GhBranchResponse,
@@ -25,7 +25,7 @@ import fetch from 'cross-fetch';
import parseGitUrl from 'git-url-parse';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { NotFoundError, NotModifiedError } from '../errors';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
import { ReadTreeResponseFactory } from './tree';
import {
ReaderFactory,
@@ -25,7 +25,7 @@ import path from 'path';
import { getVoidLogger } from '../logging';
import { GitlabUrlReader } from './GitlabUrlReader';
import { ReadTreeResponseFactory } from './tree';
import { NotModifiedError, NotFoundError } from '../errors';
import { NotModifiedError, NotFoundError } from '@backstage/errors';
import {
GitLabIntegration,
readGitLabIntegrationConfig,
@@ -24,7 +24,7 @@ import fetch from 'cross-fetch';
import parseGitUrl from 'git-url-parse';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { NotFoundError, NotModifiedError } from '../errors';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
import { ReadTreeResponseFactory } from './tree';
import { stripFirstDirectoryFromPath } from './tree/util';
import {
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { NotAllowedError } from '../errors';
import { NotAllowedError } from '@backstage/errors';
import {
ReadTreeOptions,
ReadTreeResponse,
+1
View File
@@ -31,6 +31,7 @@
"dependencies": {
"@backstage/catalog-model": "^0.7.4",
"@backstage/config": "^0.1.2",
"@backstage/errors": "^0.1.1",
"cross-fetch": "^3.0.6"
},
"devDependencies": {
+4 -11
View File
@@ -21,6 +21,7 @@ import {
LOCATION_ANNOTATION,
stringifyLocationReference,
} from '@backstage/catalog-model';
import { ResponseError } from '@backstage/errors';
import fetch from 'cross-fetch';
import {
AddLocationRequest,
@@ -153,10 +154,7 @@ export class CatalogClient implements CatalogApi {
},
);
if (!response.ok) {
const payload = await response.text();
throw new Error(
`Request failed with ${response.status} ${response.statusText}, ${payload}`,
);
throw await ResponseError.fromResponse(response);
}
return undefined;
}
@@ -177,9 +175,7 @@ export class CatalogClient implements CatalogApi {
});
if (!response.ok) {
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
throw await ResponseError.fromResponse(response);
}
return await response.json();
@@ -200,10 +196,7 @@ export class CatalogClient implements CatalogApi {
if (response.status === 404) {
return undefined;
}
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
throw await ResponseError.fromResponse(response);
}
return await response.json();
+1 -1
View File
@@ -100,7 +100,7 @@ module.exports = {
},
},
{
files: ['*.test.*', 'src/setupTests.*', 'dev/**'],
files: ['*.test.*', '*.stories.*', 'src/setupTests.*', 'dev/**'],
rules: {
// Tests are allowed to import dev dependencies
'import/no-extraneous-dependencies': [
+1
View File
@@ -31,6 +31,7 @@
"dependencies": {
"@backstage/config": "^0.1.3",
"@backstage/core-api": "^0.2.13",
"@backstage/errors": "^0.1.1",
"@backstage/theme": "^0.2.4",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -14,21 +14,11 @@
* limitations under the License.
*/
import React, { useRef, useState, MouseEventHandler } from 'react';
import { IconButton, makeStyles, Tooltip } from '@material-ui/core';
import PropTypes from 'prop-types';
import CopyIcon from '@material-ui/icons/FileCopy';
import { BackstageTheme } from '@backstage/theme';
import { errorApiRef, useApi } from '@backstage/core-api';
const useStyles = makeStyles<BackstageTheme>(theme => ({
button: {
'&:hover': {
backgroundColor: theme.palette.highlight,
cursor: 'pointer',
},
},
}));
import { IconButton, Tooltip } from '@material-ui/core';
import CopyIcon from '@material-ui/icons/FileCopy';
import PropTypes from 'prop-types';
import React, { MouseEventHandler, useRef, useState } from 'react';
/**
* Copy text button with visual feedback in the form of
@@ -61,7 +51,6 @@ export const CopyTextButton = (props: Props) => {
...defaultProps,
...props,
};
const classes = useStyles(props);
const errorApi = useApi(errorApiRef);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [open, setOpen] = useState(false);
@@ -84,7 +73,7 @@ export const CopyTextButton = (props: Props) => {
<>
<textarea
ref={inputRef}
style={{ position: 'absolute', top: -9999, left: 9999 }}
style={{ position: 'absolute', top: -9999, left: -9999 }}
defaultValue={text}
/>
<Tooltip
@@ -95,7 +84,7 @@ export const CopyTextButton = (props: Props) => {
onClose={() => setOpen(false)}
open={open}
>
<IconButton onClick={handleCopyClick} className={classes.button}>
<IconButton onClick={handleCopyClick}>
<CopyIcon />
</IconButton>
</Tooltip>
@@ -0,0 +1,135 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { ResponseError } from '@backstage/errors';
import {
Divider,
List,
ListItem,
ListItemText,
makeStyles,
} from '@material-ui/core';
import React from 'react';
import { CodeSnippet } from '../CodeSnippet';
import { CopyTextButton } from '../CopyTextButton';
import { WarningPanel } from '../WarningPanel';
const useStyles = makeStyles(theme => ({
text: {
fontFamily: 'monospace',
whiteSpace: 'pre',
overflowX: 'auto',
marginRight: theme.spacing(2),
},
divider: {
margin: theme.spacing(2),
},
}));
type Props = {
error: Error;
};
/**
* Renders details about a failed server request.
*
* Has special treatment for ResponseError errors, to display rich
* server-provided information about what happened.
*/
export const ResponseErrorDetails = ({ error }: Props) => {
const classes = useStyles();
if (error.name !== 'ResponseError') {
return (
<List dense>
<ListItemText primary="Message" secondary={error.message} />
</List>
);
}
const { data, cause } = error as ResponseError;
const { request, response } = data;
const errorString = `${response.statusCode}: ${cause.name}`;
const requestString = request && `${request.method} ${request.url}`;
const messageString = cause.message.replace(/\\n/g, '\n');
const stackString = cause.stack?.replace(/\\n/g, '\n');
const jsonString = JSON.stringify(data, undefined, 2);
return (
<List dense>
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Error"
secondary={errorString}
/>
<CopyTextButton text={errorString} />
</ListItem>
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Message"
secondary={messageString}
/>
<CopyTextButton text={messageString} />
</ListItem>
{requestString && (
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Request"
secondary={requestString}
/>
<CopyTextButton text={requestString} />
</ListItem>
)}
{stackString && (
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Stack Trace"
secondary={stackString}
/>
<CopyTextButton text={stackString} />
</ListItem>
)}
<Divider component="li" className={classes.divider} />
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Full Error as JSON"
secondary={<CodeSnippet language="json" text={jsonString} />}
/>
<CopyTextButton text={jsonString} />
</ListItem>
</List>
);
};
/**
* Renders a warning panel as the effect of a failed server request.
*
* Has special treatment for ResponseError errors, to display rich
* server-provided information about what happened.
*/
export const ResponseErrorPanel = ({ error }: Props) => {
return (
<WarningPanel title={error.message}>
<ResponseErrorDetails error={error} />
</WarningPanel>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { ResponseErrorDetails, ResponseErrorPanel } from './ResponseErrorPanel';
+1
View File
@@ -22,6 +22,7 @@ export * from './CopyTextButton';
export * from './DependencyGraph';
export * from './DismissableBanner';
export * from './EmptyState';
export * from './ResponseErrorPanel';
export * from './FeatureDiscovery';
export * from './HeaderIconLinkRow';
export * from './HorizontalScrollGrid';
+1
View File
@@ -50,6 +50,7 @@
"@backstage/cli": "*",
"@backstage/config": "*",
"@backstage/core": "*",
"@backstage/errors": "*",
"@backstage/plugin-api-docs": "*",
"@backstage/plugin-app-backend": "*",
"@backstage/plugin-auth-backend": "*",
+2
View File
@@ -36,6 +36,7 @@ import { version as catalogModel } from '../../../catalog-model/package.json';
import { version as cli } from '../../../cli/package.json';
import { version as config } from '../../../config/package.json';
import { version as core } from '../../../core/package.json';
import { version as errors } from '../../../errors/package.json';
import { version as testUtils } from '../../../test-utils/package.json';
import { version as theme } from '../../../theme/package.json';
@@ -67,6 +68,7 @@ export const packageVersions = {
'@backstage/cli': cli,
'@backstage/config': config,
'@backstage/core': core,
'@backstage/errors': errors,
'@backstage/plugin-api-docs': pluginApiDocs,
'@backstage/plugin-app-backend': pluginAppBackend,
'@backstage/plugin-auth-backend': pluginAuthBackend,
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
View File
+9
View File
@@ -0,0 +1,9 @@
# Common error handling functionality
Contains some common functionality of error handling.
This package will be imported both by the frontend and backend.
## Links
- [The Backstage homepage](https://backstage.io)
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@backstage/errors",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/errors"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.2",
"cross-fetch": "^3.0.6",
"serialize-error": "^8.0.1"
},
"devDependencies": {
"@backstage/cli": "^0.6.1",
"@types/jest": "^26.0.7"
},
"files": [
"dist"
]
}
@@ -0,0 +1,37 @@
/*
* Copyright 2020 Spotify AB
*
* 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 class CustomErrorBase extends Error {
readonly cause?: Error;
constructor(message?: string, cause?: Error) {
let fullMessage = message;
if (cause) {
if (fullMessage) {
fullMessage += `; caused by ${cause}`;
} else {
fullMessage = `caused by ${cause}`;
}
}
super(fullMessage);
Error.captureStackTrace?.(this, this.constructor);
this.name = this.constructor.name;
this.cause = cause;
}
}
@@ -0,0 +1,42 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { ErrorResponse } from '../serialization';
import { ResponseError } from './ResponseError';
describe('ResponseError', () => {
it('constructs itself from a response', async () => {
const body: ErrorResponse = {
error: { name: 'Fours', message: 'Expected fives', stack: 'lines' },
request: { method: 'GET', url: '/' },
response: { statusCode: 444 },
};
const response: Partial<Response> = {
status: 444,
statusText: 'Fours',
text: async () => JSON.stringify(body),
headers: new Headers({ 'Content-Type': 'application/json' }),
};
const e = await ResponseError.fromResponse(response as Response);
expect(e.name).toEqual('ResponseError');
expect(e.message).toEqual('Request failed with 444 Fours');
expect(e.cause.name).toEqual('Fours');
expect(e.cause.message).toEqual('Expected fives');
expect(e.cause.stack).toEqual('lines');
});
});
@@ -0,0 +1,88 @@
/*
* Copyright 2021 Spotify AB
*
* 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 {
parseErrorResponse,
ErrorResponse,
deserializeError,
} from '../serialization';
/**
* An error thrown as the result of a failed server request.
*
* The server is expected to respond on the ErrorResponse format.
*/
export class ResponseError extends Error {
/**
* The actual response, as seen by the client.
*
* Note that the body of this response is always consumed. Its parsed form is
* in the `body` field.
*/
readonly response: Response;
/**
* The parsed JSON error body, as sent by the server.
*/
readonly data: ErrorResponse;
/**
* The Error cause, as seen by the remote server. This is parsed out of the
* JSON error body.
*
* This error always has the plain Error constructor, however all
* serializable enumerable fields on the remote error including its name are
* preserved. Therefore, if you want to check the error type, use its name
* property rather than checking typeof or its constructor or prototype.
*/
readonly cause: Error;
/**
* Constructs a ResponseError based on a failed response.
*
* Assumes that the response has already been checked to be not ok. This
* function consumes the body of the response, and assumes that it hasn't
* been consumed before.
*/
static async fromResponse(response: Response): Promise<ResponseError> {
const data = await parseErrorResponse(response);
const status = data.response.statusCode || response.status;
const statusText = data.error.name || response.statusText;
const message = `Request failed with ${status} ${statusText}`;
const cause = deserializeError(data.error);
return new ResponseError({
message,
response,
data,
cause,
});
}
constructor(props: {
message: string;
response: Response;
data: ErrorResponse;
cause: Error;
}) {
super(props.message);
this.name = 'ResponseError';
this.response = props.response;
this.data = props.data;
this.cause = props.cause;
}
}
@@ -14,9 +14,9 @@
* limitations under the License.
*/
import * as errors from './errors';
import * as errors from './common';
describe('errors', () => {
describe('common', () => {
it('extends Error properly', () => {
for (const [name, E] of Object.entries(errors)) {
const error = new E('abcdef');
@@ -30,7 +30,7 @@ describe('errors', () => {
it('supports causes', () => {
const cause = new Error('hello');
for (const [, E] of Object.entries(errors)) {
for (const [name, E] of Object.entries(errors)) {
const error = new E('abcdef', cause);
expect(error.cause).toBe(cause);
expect(error.toString()).toContain(
@@ -14,39 +14,19 @@
* limitations under the License.
*/
import { CustomErrorBase } from './CustomErrorBase';
/*
* A set of common business logic errors.
*
* The error handler middleware understands these and will translate them to
* well formed HTTP responses.
* A backend error handler middleware would understand these and translate them
* to well formed HTTP responses.
*
* While these are intentionally analogous to HTTP errors, they are not
* intended to be thrown by the request handling layer. In those places, please
* use e.g. the http-errors library.
*/
class CustomErrorBase extends Error {
readonly cause?: Error;
constructor(message?: string, cause?: Error) {
let fullMessage = message;
if (cause) {
if (fullMessage) {
fullMessage += `; caused by ${cause}`;
} else {
fullMessage = `caused by ${cause}`;
}
}
super(fullMessage);
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.cause = cause;
}
}
/**
* The given inputs are malformed and cannot be processed.
*/
+26
View File
@@ -0,0 +1,26 @@
/*
* Copyright 2021 Spotify AB
*
* 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 {
AuthenticationError,
ConflictError,
InputError,
NotAllowedError,
NotFoundError,
NotModifiedError,
} from './common';
export { CustomErrorBase } from './CustomErrorBase';
export { ResponseError } from './ResponseError';
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* 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 './errors';
export * from './serialization';
@@ -0,0 +1,64 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { deserializeError, serializeError } from './error';
class CustomError extends Error {
readonly customField: any;
constructor(message: string) {
super(message);
this.name = 'CustomError';
this.customField = { complex: 'data' };
}
}
describe('serialization', () => {
it('runs the happy path with stack', () => {
const before = new CustomError('m');
const after = deserializeError<CustomError>(
serializeError(before, { includeStack: true }),
);
expect(after.message).toEqual(before.message);
expect(after.name).toEqual(before.name);
expect(after.stack).toEqual(before.stack);
expect(after.customField).toEqual(before.customField);
// Does not reconstruct the actual class!
expect(after.constructor).not.toBe(before.constructor);
});
it('runs the happy path without stack', () => {
const before = new CustomError('m');
const after = deserializeError<CustomError>(
serializeError(before, { includeStack: false }),
);
expect(after.message).toEqual(before.message);
expect(after.name).toEqual(before.name);
expect(after.stack).not.toEqual(before.stack);
expect(after.customField).toEqual(before.customField);
// Does not reconstruct the actual class!
expect(after.constructor).not.toBe(before.constructor);
});
it('serializes stack traces only when allowed', () => {
const before = new CustomError('m');
const withStack = serializeError(before, { includeStack: true });
const withoutStack1 = serializeError(before, { includeStack: false });
const withoutStack2 = serializeError(before);
expect(withStack.stack).toEqual(before.stack);
expect(withoutStack1.stack).not.toBeDefined();
expect(withoutStack2.stack).not.toBeDefined();
});
});
@@ -0,0 +1,72 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { JsonObject } from '@backstage/config';
import {
deserializeError as deserializeErrorInternal,
serializeError as serializeErrorInternal,
} from 'serialize-error';
/**
* The serialized form of an Error.
*/
export type SerializedError = JsonObject & {
/** The name of the exception that was thrown */
name: string;
/** The message of the exception that was thrown */
message: string;
/** A stringified stack trace; may not be present */
stack?: string;
/** A custom code (not necessarily the same as an HTTP response code); may not be present */
code?: string;
};
/**
* Serializes an error object to a JSON friendly form.
*
* @param error The error
* @param options.includeStackTraces: Include stack trace in the output (default false)
*/
export function serializeError(
error: Error,
options?: { includeStack?: boolean },
): SerializedError {
const serialized = serializeErrorInternal(error);
const result: SerializedError = {
name: 'Unknown',
message: '<no reason given>',
...serialized,
};
if (!options?.includeStack) {
delete result.stack;
}
return result;
}
/**
* Deserializes a serialized error object back to an Error.
*/
export function deserializeError<T extends Error = Error>(
data: SerializedError,
): T {
const result = deserializeErrorInternal(data) as T;
if (!data.stack) {
result.stack = undefined;
}
return result;
}
@@ -0,0 +1,20 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { deserializeError, serializeError } from './error';
export type { SerializedError } from './error';
export { parseErrorResponse } from './response';
export type { ErrorResponse } from './response';
@@ -0,0 +1,107 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { parseErrorResponse, ErrorResponse } from './response';
describe('parseErrorResponse', () => {
it('handles the happy path', async () => {
const body: ErrorResponse = {
error: { name: 'Fours', message: 'Expected fives', stack: 'lines' },
request: { method: 'GET', url: '/' },
response: { statusCode: 444 },
};
const response: Partial<Response> = {
status: 444,
statusText: 'Fours',
text: async () => JSON.stringify(body),
headers: new Headers({ 'Content-Type': 'application/json' }),
};
await expect(parseErrorResponse(response as Response)).resolves.toEqual(
body,
);
});
it('uses request header and text body when wrong content type, even if parsable', async () => {
const body: ErrorResponse = {
error: { name: 'Threes', message: 'Expected twos' },
request: { method: 'GET', url: '/' },
response: { statusCode: 333 },
};
const response: Partial<Response> = {
status: 444,
statusText: 'Fours',
text: async () => JSON.stringify(body),
headers: new Headers({ 'Content-Type': 'not-application/not-json' }),
};
await expect(parseErrorResponse(response as Response)).resolves.toEqual({
error: {
name: 'Unknown',
message: `Request failed with status 444 Fours, ${JSON.stringify(
body,
)}`,
},
response: { statusCode: 444 },
});
});
it('uses request header and text body when not parsable', async () => {
const body: ErrorResponse = {
error: { name: 'Threes', message: 'Expected twos' },
request: { method: 'GET', url: '/' },
response: { statusCode: 333 },
};
const response: Partial<Response> = {
status: 444,
statusText: 'Fours',
text: async () => JSON.stringify(body).substring(1),
headers: new Headers({ 'Content-Type': 'application/json' }),
};
await expect(parseErrorResponse(response as Response)).resolves.toEqual({
error: {
name: 'Unknown',
message: `Request failed with status 444 Fours, ${JSON.stringify(
body,
).substring(1)}`,
},
response: { statusCode: 444 },
});
});
it('uses request header when failing to get body', async () => {
const response: Partial<Response> = {
status: 444,
statusText: 'Fours',
text: async () => {
throw new Error('bail');
},
headers: new Headers({ 'Content-Type': 'application/json' }),
};
await expect(parseErrorResponse(response as Response)).resolves.toEqual({
error: {
name: 'Unknown',
message: `Request failed with status 444 Fours`,
},
response: { statusCode: 444 },
});
});
});
@@ -0,0 +1,94 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { SerializedError } from './error';
/**
* A standard shape of JSON data returned as the body of backend errors.
*/
export type ErrorResponse = {
/** Details of the error that was caught */
error: SerializedError;
/** Details about the incoming request */
request?: {
/** The HTTP method of the request */
method: string;
/** The URL of the request (excluding protocol and host/port) */
url: string;
};
/** Details about the response */
response: {
/** The numeric HTTP status code that was returned */
statusCode: number;
};
};
/**
* Attempts to construct an ErrorResponse out of a failed server request.
* Assumes that the response has already been checked to be not ok. This
* function consumes the body of the response, and assumes that it hasn't
* been consumed before.
*
* The code is forgiving, and constructs a useful synthetic body as best it can
* if the response body wasn't on the expected form.
*
* @param response The response of a failed request
*/
export async function parseErrorResponse(
response: Response,
): Promise<ErrorResponse> {
try {
const text = await response.text();
if (text) {
if (
response.headers.get('content-type')?.startsWith('application/json')
) {
try {
const body = JSON.parse(text);
if (body.error && body.response) {
return body;
}
} catch {
// ignore
}
}
return {
error: {
name: 'Unknown',
message: `Request failed with status ${response.status} ${response.statusText}, ${text}`,
},
response: {
statusCode: response.status,
},
};
}
} catch {
// ignore
}
return {
error: {
name: 'Unknown',
message: `Request failed with status ${response.status} ${response.statusText}`,
},
response: {
statusCode: response.status,
},
};
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {};
+1
View File
@@ -41,6 +41,7 @@
"@backstage/backend-common": "^0.5.6",
"@backstage/catalog-model": "^0.7.4",
"@backstage/config": "^0.1.3",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.1",
"@google-cloud/storage": "^5.6.0",
"@types/dockerode": "^3.2.1",
+2 -1
View File
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Git, InputError, UrlReader } from '@backstage/backend-common';
import { Git, UrlReader } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { Entity, parseLocationReference } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import fs from 'fs-extra';
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NotModifiedError } from '@backstage/backend-common';
import { NotModifiedError } from '@backstage/errors';
import { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import parseGitUrl from 'git-url-parse';
@@ -13,11 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
InputError,
NotModifiedError,
UrlReader,
} from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
import { InputError, NotModifiedError } from '@backstage/errors';
import { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import parseGitUrl from 'git-url-parse';
@@ -13,7 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NotModifiedError, UrlReader } from '@backstage/backend-common';
import { NotModifiedError } from '@backstage/errors';
import { UrlReader } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { Logger } from 'winston';
import { getDocFilesFromRepository } from '../../helpers';
+1
View File
@@ -33,6 +33,7 @@
"@backstage/catalog-client": "^0.3.7",
"@backstage/catalog-model": "^0.7.4",
"@backstage/config": "^0.1.3",
"@backstage/errors": "^0.1.1",
"@backstage/test-utils": "^0.1.8",
"@types/express": "^4.17.6",
"@types/passport": "^1.0.3",
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
@@ -22,7 +22,7 @@ import {
BackstageIdentity,
AuthProviderConfig,
} from '../../providers/types';
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { TokenIssuer } from '../../identity';
import { verifyNonce } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
@@ -16,7 +16,7 @@
import express from 'express';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { readState } from './helpers';
import { AuthProviderRouteHandlers } from '../../providers/types';
+1 -1
View File
@@ -23,10 +23,10 @@ import {
AuthProviderFactory,
} from '../providers';
import {
NotFoundError,
PluginDatabaseManager,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { NotFoundError } from '@backstage/errors';
import { CatalogClient } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { createOidcRouter, DatabaseKeyStore, TokenFactory } from '../identity';
+1
View File
@@ -33,6 +33,7 @@
"@backstage/backend-common": "^0.5.6",
"@backstage/catalog-model": "^0.7.4",
"@backstage/config": "^0.1.3",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.1",
"@octokit/graphql": "^4.5.8",
"@types/express": "^4.17.6",
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import { ConflictError, NotFoundError } from '@backstage/errors';
import {
Entity,
entityHasChanges,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ConflictError } from '@backstage/backend-common';
import { ConflictError } from '@backstage/errors';
import { Entity, Location, parseEntityRef } from '@backstage/catalog-model';
import { EntityFilters } from '../service/EntityFilters';
import { DatabaseManager } from './DatabaseManager';
@@ -14,11 +14,7 @@
* limitations under the License.
*/
import {
ConflictError,
InputError,
NotFoundError,
} from '@backstage/backend-common';
import { ConflictError, InputError, NotFoundError } from '@backstage/errors';
import {
Entity,
EntityName,
@@ -14,13 +14,15 @@
* limitations under the License.
*/
import { NotAllowedError, UrlReader } from '@backstage/backend-common';
import { NotAllowedError } from '@backstage/errors';
import { UrlReader } from '@backstage/backend-common';
import {
Entity,
EntityPolicy,
EntityRelationSpec,
ENTITY_DEFAULT_NAMESPACE,
LocationSpec,
stringifyLocationReference,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
@@ -103,7 +105,11 @@ export class LocationReaders implements LocationReader {
output.errors.push({
location: item.location,
error: new NotAllowedError(
`Entity of kind ${item.entity.kind} is not allowed from location ${item.location.type} ${item.location.target}`,
`Entity of kind ${
item.entity.kind
} is not allowed from location ${stringifyLocationReference(
item.location,
)}`,
),
});
}
@@ -166,14 +172,20 @@ export class LocationReaders implements LocationReader {
return;
}
} catch (e) {
const message = `Processor ${processor.constructor.name} threw an error while reading location ${item.location.type} ${item.location.target}, ${e}`;
const message = `Processor ${
processor.constructor.name
} threw an error while reading location ${stringifyLocationReference(
item.location,
)}, ${e}`;
emit(result.generalError(item.location, message));
logger.warn(message);
}
}
}
const message = `No processor was able to read location ${item.location.type} ${item.location.target}`;
const message = `No processor was able to read location ${stringifyLocationReference(
item.location,
)}`;
emit(result.inputError(item.location, message));
logger.warn(message);
}
@@ -205,7 +217,11 @@ export class LocationReaders implements LocationReader {
originLocation,
);
} catch (e) {
const message = `Processor ${processor.constructor.name} threw an error while preprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
const message = `Processor ${
processor.constructor.name
} threw an error while preprocessing entity ${kind}:${namespace}/${name} at ${stringifyLocationReference(
item.location,
)}, ${e}`;
emit(result.generalError(item.location, e.message));
logger.warn(message);
return undefined;
@@ -216,14 +232,18 @@ export class LocationReaders implements LocationReader {
try {
const next = await this.options.policy.enforce(current);
if (!next) {
const message = `Policy unexpectedly returned no data while analyzing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}`;
const message = `Policy unexpectedly returned no data while analyzing entity ${kind}:${namespace}/${name} at ${stringifyLocationReference(
item.location,
)}`;
emit(result.generalError(item.location, message));
logger.warn(message);
return undefined;
}
current = next;
} catch (e) {
const message = `Policy check failed while analyzing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
const message = `Policy check failed while analyzing entity ${kind}:${namespace}/${name} at ${stringifyLocationReference(
item.location,
)}, ${e}`;
emit(result.inputError(item.location, e.message));
logger.warn(message);
return undefined;
@@ -238,7 +258,11 @@ export class LocationReaders implements LocationReader {
break;
}
} catch (e) {
const message = `Processor ${processor.constructor.name} threw an error while validating the entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
const message = `Processor ${
processor.constructor.name
} threw an error while validating the entity ${kind}:${namespace}/${name} at ${stringifyLocationReference(
item.location,
)}, ${e}`;
emit(result.inputError(item.location, message));
logger.warn(message);
return undefined;
@@ -246,7 +270,9 @@ export class LocationReaders implements LocationReader {
}
}
if (!handled) {
const message = `No processor recognized the entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}`;
const message = `No processor recognized the entity ${kind}:${namespace}/${name} at ${stringifyLocationReference(
item.location,
)}`;
emit(result.inputError(item.location, message));
logger.warn(message);
return undefined;
@@ -261,7 +287,11 @@ export class LocationReaders implements LocationReader {
emit,
);
} catch (e) {
const message = `Processor ${processor.constructor.name} threw an error while postprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
const message = `Processor ${
processor.constructor.name
} threw an error while postprocessing entity ${kind}:${namespace}/${name} at ${stringifyLocationReference(
item.location,
)}, ${e}`;
emit(result.generalError(item.location, message));
logger.warn(message);
return undefined;
@@ -279,7 +309,9 @@ export class LocationReaders implements LocationReader {
const { processors, logger } = this.options;
logger.debug(
`Encountered error at location ${item.location.type} ${item.location.target}, ${item.error}`,
`Encountered error at location ${stringifyLocationReference(
item.location,
)}, ${item.error}`,
);
const validatedEmit: CatalogProcessorEmit = emitResult => {
@@ -295,7 +327,11 @@ export class LocationReaders implements LocationReader {
try {
await processor.handleError(item.error, item.location, validatedEmit);
} catch (e) {
const message = `Processor ${processor.constructor.name} threw an error while handling another error at ${item.location.type} ${item.location.target}, ${e}`;
const message = `Processor ${
processor.constructor.name
} threw an error while handling another error at ${stringifyLocationReference(
item.location,
)}, ${e}`;
emit(result.generalError(item.location, message));
logger.warn(message);
}
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { NotFoundError, UrlReader } from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
import { NotFoundError } from '@backstage/errors';
import {
Entity,
LocationSpec,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError, NotFoundError } from '@backstage/backend-common';
import { InputError, NotFoundError } from '@backstage/errors';
import {
Entity,
EntityRelationSpec,
@@ -116,7 +116,7 @@ describe('parseEntityYaml', () => {
});
it('should handle empty yaml documents', () => {
// This happens if the user accidentially adds a "---"
// This happens if the user accidentally adds a "---"
// at the end of a file
const results = Array.from(
parseEntityYaml(
@@ -205,7 +205,7 @@ describe('parseEntityYaml', () => {
it('must be an object at root', () => {
const results = Array.from(
parseEntityYaml(Buffer.from('imma-string', 'utf8'), testLoc),
parseEntityYaml(Buffer.from('i-am-a-string', 'utf8'), testLoc),
);
expect(results).toEqual([
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { EntitiesSearchFilter, EntityFilter } from '../database';
/**
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { Entity } from '@backstage/catalog-model';
import lodash from 'lodash';
import { RecursivePartial } from '../util';
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { getVoidLogger, NotFoundError } from '@backstage/backend-common';
import { getVoidLogger } from '@backstage/backend-common';
import { NotFoundError } from '@backstage/errors';
import type { Entity, LocationSpec } from '@backstage/catalog-model';
import express from 'express';
import request from 'supertest';
@@ -14,20 +14,21 @@
* limitations under the License.
*/
import { errorHandler, NotFoundError } from '@backstage/backend-common';
import {
locationSpecSchema,
analyzeLocationSchema,
} from '@backstage/catalog-model';
import { errorHandler } from '@backstage/backend-common';
import type { Entity } from '@backstage/catalog-model';
import {
analyzeLocationSchema,
locationSpecSchema,
} from '@backstage/catalog-model';
import { NotFoundError } from '@backstage/errors';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import yn from 'yn';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
import { LocationAnalyzer, HigherOrderOperation } from '../ingestion/types';
import { translateQueryToFieldMapper } from './filterQuery';
import { HigherOrderOperation, LocationAnalyzer } from '../ingestion/types';
import { EntityFilters } from './EntityFilters';
import { translateQueryToFieldMapper } from './filterQuery';
import { requireRequestBody, validateRequestBody } from './util';
export interface RouterOptions {
@@ -105,7 +106,7 @@ export async function createRouter(
);
if (!entities.length) {
throw new NotFoundError(
`No entity with kind ${kind} namespace ${namespace} name ${name}`,
`No entity named '${name}' found, with kind '${kind}' in namespace '${namespace}'`,
);
}
res.status(200).json(entities[0]);
+1 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { Request } from 'express';
import lodash from 'lodash';
import yup from 'yup';
@@ -25,6 +25,7 @@ import {
Link,
Page,
Progress,
ResponseErrorPanel,
WarningPanel,
} from '@backstage/core';
import {
@@ -34,7 +35,6 @@ import {
useEntityCompoundName,
} from '@backstage/plugin-catalog-react';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { PropsWithChildren, useContext, useState } from 'react';
import { useNavigate } from 'react-router';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
@@ -143,7 +143,7 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => {
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
<ResponseErrorPanel error={error} />
</Content>
)}
+4
View File
@@ -14,6 +14,9 @@
* limitations under the License.
*/
import { IconComponent } from '@backstage/core';
import GraphiQLIconComponent from './assets/graphiql.icon.svg';
export {
graphiqlPlugin,
graphiqlPlugin as plugin,
@@ -22,3 +25,4 @@ export {
export { GraphiQLPage as Router } from './components';
export * from './lib/api';
export * from './route-refs';
export const GraphiQLIcon: IconComponent = GraphiQLIconComponent;
+1
View File
@@ -33,6 +33,7 @@
"@backstage/catalog-client": "^0.3.7",
"@backstage/catalog-model": "^0.7.4",
"@backstage/config": "^0.1.3",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.1",
"@gitbeaker/core": "^28.0.2",
"@gitbeaker/node": "^28.0.2",
@@ -19,7 +19,7 @@ import {
TemplateEntityV1beta2,
} from '@backstage/catalog-model';
import { CatalogApi } from '@backstage/catalog-client';
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import { ConflictError, NotFoundError } from '@backstage/errors';
/**
* A catalog client tailored for reading out entity data from the catalog.
@@ -15,7 +15,7 @@
*/
import { InputBase, TemplateAction } from './types';
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import { ConflictError, NotFoundError } from '@backstage/errors';
export class TemplateActionRegistry {
private readonly actions = new Map<string, TemplateAction<any>>();
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import { CatalogApi } from '@backstage/catalog-client';
import { getEntityName } from '@backstage/catalog-model';
@@ -17,7 +17,8 @@
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import Docker from 'dockerode';
import { InputError, UrlReader } from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import { JsonObject } from '@backstage/config';
import { TemplaterBuilder, TemplaterValues } from '../../../stages/templater';
@@ -16,7 +16,8 @@
import fs from 'fs-extra';
import { resolve as resolvePath, isAbsolute } from 'path';
import { InputError, UrlReader } from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import { JsonValue } from '@backstage/config';
@@ -15,7 +15,8 @@
*/
import path from 'path';
import { InputError, UrlReader } from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import { fetchContents } from './helpers';
import { createTemplateAction } from '../../createTemplateAction';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import {
BitbucketIntegrationConfig,
ScmIntegrationRegistry,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import {
GithubCredentialsProvider,
ScmIntegrationRegistry,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { Gitlab } from '@gitbeaker/node';
import { initRepoAndPush } from '../../../stages/publish/helpers';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
export const parseRepoUrl = (repoUrl: string) => {
let parsed;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import {
LOCATION_ANNOTATION,
parseLocationReference,
@@ -16,7 +16,7 @@
import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { PreparerBase, PreparerOptions } from './types';
export class FilePreparer implements PreparerBase {
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { spawn } from 'child_process';
import { PassThrough, Writable } from 'stream';
@@ -15,11 +15,8 @@
*/
import { JsonObject } from '@backstage/config';
import {
ConflictError,
NotFoundError,
resolvePackagePath,
} from '@backstage/backend-common';
import { resolvePackagePath } from '@backstage/backend-common';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { Knex } from 'knex';
import { v4 as uuid } from 'uuid';
import {
@@ -24,7 +24,7 @@ import fs from 'fs-extra';
import path from 'path';
import { TemplateActionRegistry } from '../actions/TemplateActionRegistry';
import * as handlebars from 'handlebars';
import { InputError } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
type Options = {
logger: Logger;
@@ -42,12 +42,8 @@ import { templateEntityToSpec } from '../scaffolder/tasks/TemplateConverter';
import { TemplateActionRegistry } from '../scaffolder/actions/TemplateActionRegistry';
import { createLegacyActions } from '../scaffolder/stages/legacy';
import { getEntityBaseUrl, getWorkingDirectory } from './helpers';
import {
InputError,
NotFoundError,
PluginDatabaseManager,
UrlReader,
} from '@backstage/backend-common';
import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common';
import { InputError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import {
TemplateEntityV1alpha1,
+1
View File
@@ -33,6 +33,7 @@
"@backstage/backend-common": "^0.5.6",
"@backstage/catalog-model": "^0.7.4",
"@backstage/config": "^0.1.3",
"@backstage/errors": "^0.1.1",
"@backstage/techdocs-common": "^0.4.4",
"@types/dockerode": "^3.2.1",
"@types/express": "^4.17.6",
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NotModifiedError } from '@backstage/backend-common';
import { NotModifiedError } from '@backstage/errors';
import { Entity, serializeEntityRef } from '@backstage/catalog-model';
import {
GeneratorBase,
@@ -64,7 +64,9 @@ describe('<AuthProviders />', () => {
expect(rendered.getByText('Google')).toBeInTheDocument();
expect(
rendered.getByText(googleAuthApiRef.description),
rendered.getByText(
'Provides authentication towards Google APIs and identities',
),
).toBeInTheDocument();
const button = rendered.getByTitle('Sign in to Google');
@@ -34,7 +34,7 @@ export const DefaultProviderSettings = ({ configuredProviders }: Props) => (
{configuredProviders.includes('google') && (
<ProviderSettingsItem
title="Google"
description={googleAuthApiRef.description}
description="Provides authentication towards Google APIs and identities"
apiRef={googleAuthApiRef}
icon={Star}
/>
@@ -42,7 +42,7 @@ export const DefaultProviderSettings = ({ configuredProviders }: Props) => (
{configuredProviders.includes('microsoft') && (
<ProviderSettingsItem
title="Microsoft"
description={microsoftAuthApiRef.description}
description="Provides authentication towards Microsoft APIs and identities"
apiRef={microsoftAuthApiRef}
icon={Star}
/>
@@ -50,7 +50,7 @@ export const DefaultProviderSettings = ({ configuredProviders }: Props) => (
{configuredProviders.includes('github') && (
<ProviderSettingsItem
title="GitHub"
description={githubAuthApiRef.description}
description="Provides authentication towards GitHub APIs"
apiRef={githubAuthApiRef}
icon={Star}
/>
@@ -58,7 +58,7 @@ export const DefaultProviderSettings = ({ configuredProviders }: Props) => (
{configuredProviders.includes('gitlab') && (
<ProviderSettingsItem
title="GitLab"
description={gitlabAuthApiRef.description}
description="Provides authentication towards GitLab APIs"
apiRef={gitlabAuthApiRef}
icon={Star}
/>
@@ -66,7 +66,7 @@ export const DefaultProviderSettings = ({ configuredProviders }: Props) => (
{configuredProviders.includes('okta') && (
<ProviderSettingsItem
title="Okta"
description={oktaAuthApiRef.description}
description="Provides authentication towards Okta APIs"
apiRef={oktaAuthApiRef}
icon={Star}
/>
@@ -74,7 +74,7 @@ export const DefaultProviderSettings = ({ configuredProviders }: Props) => (
{configuredProviders.includes('oauth2') && (
<ProviderSettingsItem
title="YourOrg"
description={oauth2ApiRef.description}
description="Example of how to use oauth2 custom provider"
apiRef={oauth2ApiRef}
icon={Star}
/>
+15
View File
@@ -1772,6 +1772,7 @@
dependencies:
"@backstage/config" "^0.1.3"
"@backstage/core-api" "^0.2.13"
"@backstage/errors" "^0.1.1"
"@backstage/theme" "^0.2.4"
"@material-ui/core" "^4.11.0"
"@material-ui/icons" "^4.9.1"
@@ -1813,6 +1814,7 @@
dependencies:
"@backstage/config" "^0.1.3"
"@backstage/core-api" "^0.2.13"
"@backstage/errors" "^0.1.1"
"@backstage/theme" "^0.2.4"
"@material-ui/core" "^4.11.0"
"@material-ui/icons" "^4.9.1"
@@ -1854,6 +1856,7 @@
dependencies:
"@backstage/config" "^0.1.3"
"@backstage/core-api" "^0.2.13"
"@backstage/errors" "^0.1.1"
"@backstage/theme" "^0.2.4"
"@material-ui/core" "^4.11.0"
"@material-ui/icons" "^4.9.1"
@@ -23437,6 +23440,13 @@ serialize-error@^2.1.0:
resolved "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz#50b679d5635cdf84667bdc8e59af4e5b81d5f60a"
integrity sha1-ULZ51WNc34Rme9yOWa9OW4HV9go=
serialize-error@^8.0.1:
version "8.0.1"
resolved "https://registry.npmjs.org/serialize-error/-/serialize-error-8.0.1.tgz#7a67f8ecbbf28973b5a954a2852ff9f4eef52d99"
integrity sha512-r5o60rWFS+8/b49DNAbB+GXZA0SpDpuWE758JxDKgRTga05r3U5lwyksE91dYKDhXSmnu36RALj615E6Aj5pSg==
dependencies:
type-fest "^0.20.2"
serialize-javascript@^2.1.2:
version "2.1.2"
resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61"
@@ -25513,6 +25523,11 @@ type-fest@^0.18.0:
resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f"
integrity sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==
type-fest@^0.20.2:
version "0.20.2"
resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
type-fest@^0.4.1:
version "0.4.1"
resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.4.1.tgz#8bdf77743385d8a4f13ba95f610f5ccd68c728f8"