Merge pull request #18915 from backstage/rugvip/proxy-refactor

proxy-backend: move proxy configuration to proxy.endpoints
This commit is contained in:
Patrik Oldsberg
2023-08-07 12:09:36 +02:00
committed by GitHub
12 changed files with 290 additions and 189 deletions
+1 -8
View File
@@ -13,14 +13,7 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common';
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @alpha
export const proxyPlugin: (
options?:
| {
skipInvalidProxies?: boolean | undefined;
reviveConsumedRequestBodies?: boolean | undefined;
}
| undefined,
) => BackendFeature;
export const proxyPlugin: () => BackendFeature;
// @public (undocumented)
export interface RouterOptions {
+56 -44
View File
@@ -15,51 +15,63 @@
*/
export interface Config {
/**
* A list of forwarding-proxies. Each key is a route to match,
* below the prefix that the proxy plugin is mounted on. It must
* start with a '/'.
*/
proxy?: {
[key: string]:
| string
| {
/**
* Target of the proxy. Url string to be parsed with the url module.
*/
target: string;
/**
* Object with extra headers to be added to target requests.
*/
headers?: {
/** @visibility secret */
Authorization?: string;
/** @visibility secret */
authorization?: string;
/** @visibility secret */
'X-Api-Key'?: string;
/** @visibility secret */
'x-api-key'?: string;
[key: string]: string | undefined;
/**
* Rather than failing to start up, the proxy backend will instead just warn on invalid endpoints.
*/
skipInvalidProxies?: boolean;
/**
* Revive request bodies that have already been consumed by earlier middleware.
*/
reviveConsumedRequestBodies?: boolean;
/**
* A list of forwarding-proxies. Each key is a route to match,
* below the prefix that the proxy plugin is mounted on. It must
* start with a '/'.
*/
endpoints?: {
[key: string]:
| string
| {
/**
* Target of the proxy. Url string to be parsed with the url module.
*/
target: string;
/**
* Object with extra headers to be added to target requests.
*/
headers?: {
/** @visibility secret */
Authorization?: string;
/** @visibility secret */
authorization?: string;
/** @visibility secret */
'X-Api-Key'?: string;
/** @visibility secret */
'x-api-key'?: string;
[key: string]: string | undefined;
};
/**
* Changes the origin of the host header to the target URL. Default: true.
*/
changeOrigin?: boolean;
/**
* Rewrite target's url path. Object-keys will be used as RegExp to match paths.
* If pathRewrite is not specified, it is set to a single rewrite that removes the entire prefix and route.
*/
pathRewrite?: { [regexp: string]: string };
/**
* Limit the forwarded HTTP methods, for example allowedMethods: ['GET'] to enforce read-only access.
*/
allowedMethods?: string[];
/**
* Limit the forwarded HTTP methods. By default, only the headers that are considered safe for CORS
* and headers that are set by the proxy will be forwarded.
*/
allowedHeaders?: string[];
};
/**
* Changes the origin of the host header to the target URL. Default: true.
*/
changeOrigin?: boolean;
/**
* Rewrite target's url path. Object-keys will be used as RegExp to match paths.
* If pathRewrite is not specified, it is set to a single rewrite that removes the entire prefix and route.
*/
pathRewrite?: { [regexp: string]: string };
/**
* Limit the forwarded HTTP methods, for example allowedMethods: ['GET'] to enforce read-only access.
*/
allowedMethods?: string[];
/**
* Limit the forwarded HTTP methods. By default, only the headers that are considered safe for CORS
* and headers that are set by the proxy will be forwarded.
*/
allowedHeaders?: string[];
};
};
};
}
+2
View File
@@ -48,7 +48,9 @@
"yup": "^0.32.9"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/config-loader": "workspace:^",
"@types/http-proxy-middleware": "^0.19.3",
"@types/supertest": "^2.0.8",
"@types/uuid": "^8.0.0",
+22 -29
View File
@@ -26,32 +26,25 @@ import { createRouter } from './service/router';
*
* @alpha
*/
export const proxyPlugin = createBackendPlugin(
(options?: {
skipInvalidProxies?: boolean;
reviveConsumedRequestBodies?: boolean;
}) => ({
pluginId: 'proxy',
register(env) {
env.registerInit({
deps: {
config: coreServices.rootConfig,
discovery: coreServices.discovery,
logger: coreServices.logger,
httpRouter: coreServices.httpRouter,
},
async init({ config, discovery, logger, httpRouter }) {
httpRouter.use(
await createRouter({
config,
discovery,
logger: loggerToWinstonLogger(logger),
skipInvalidProxies: options?.skipInvalidProxies,
reviveConsumedRequestBodies: options?.reviveConsumedRequestBodies,
}),
);
},
});
},
}),
);
export const proxyPlugin = createBackendPlugin({
pluginId: 'proxy',
register(env) {
env.registerInit({
deps: {
config: coreServices.rootConfig,
discovery: coreServices.discovery,
logger: coreServices.logger,
httpRouter: coreServices.httpRouter,
},
async init({ config, discovery, logger, httpRouter }) {
httpRouter.use(
await createRouter({
config,
discovery,
logger: loggerToWinstonLogger(logger),
}),
);
},
});
},
});
@@ -15,7 +15,11 @@
*/
import { getVoidLogger, SingleHostDiscovery } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import {
ConfigSources,
MutableConfigSource,
StaticConfigSource,
} from '@backstage/config-loader';
import express from 'express';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
@@ -56,34 +60,36 @@ describe('createRouter reloadable configuration', () => {
const logger = getVoidLogger();
// Grab the subscriber function and use mutable config data to mock a config file change
let subscriber: () => void;
const mutableConfigData: any = {
backend: {
baseUrl: 'http://localhost:7007',
listen: {
port: 7007,
},
},
proxy: {
'/test': {
target: 'https://non-existing-example.com',
pathRewrite: {
'.*': '/',
const mutableConfigSource = MutableConfigSource.create({ data: {} });
const config = await ConfigSources.toConfig(
ConfigSources.merge([
StaticConfigSource.create({
data: {
backend: {
baseUrl: 'http://localhost:7007',
listen: {
port: 7007,
},
},
proxy: {
endpoints: {
'/test': {
target: 'https://non-existing-example.com',
pathRewrite: {
'.*': '/',
},
},
},
},
},
},
},
};
}),
mutableConfigSource,
]),
);
const mockConfig = Object.assign(new ConfigReader(mutableConfigData), {
subscribe: (s: () => void) => {
subscriber = s;
return { unsubscribe: () => {} };
},
});
const discovery = SingleHostDiscovery.fromConfig(mockConfig);
const discovery = SingleHostDiscovery.fromConfig(config);
const router = await createRouter({
config: mockConfig,
config,
logger,
discovery,
});
@@ -100,13 +106,18 @@ describe('createRouter reloadable configuration', () => {
expect(response1.status).toEqual(200);
mutableConfigData.proxy['/test2'] = {
target: 'https://non-existing-example.com',
pathRewrite: {
'.*': '/',
mutableConfigSource.setData({
proxy: {
endpoints: {
'/test2': {
target: 'https://non-existing-example.com',
pathRewrite: {
'.*': '/',
},
},
},
},
};
subscriber!();
});
const response2 = await agent.get('/test2');
@@ -15,6 +15,7 @@
*/
import { getVoidLogger, SingleHostDiscovery } from '@backstage/backend-common';
import { mockServices } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { Request, Response } from 'express';
import * as http from 'http';
@@ -45,10 +46,12 @@ describe('createRouter', () => {
},
},
proxy: {
'/test': {
target: 'https://example.com',
headers: {
Authorization: 'Bearer supersecret',
endpoints: {
'/test': {
target: 'https://example.com',
headers: {
Authorization: 'Bearer supersecret',
},
},
},
},
@@ -68,6 +71,32 @@ describe('createRouter', () => {
expect(router).toBeDefined();
});
it('supports deprecated proxy configuration', async () => {
const router = await createRouter({
config: mockServices.rootConfig({
data: {
proxy: {
'/test': {
target: 'https://example.com',
headers: {
Authorization: 'Bearer supersecret',
},
},
},
},
}),
logger,
discovery,
});
expect(router).toBeDefined();
expect(mockCreateProxyMiddleware).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
target: 'https://example.com',
}),
);
});
it('revives request bodies when set', async () => {
const router = await createRouter({
config,
@@ -112,9 +141,11 @@ describe('createRouter', () => {
},
// no target would cause the buildMiddleware to fail
proxy: {
'/test': {
headers: {
Authorization: 'Bearer supersecret',
endpoints: {
'/test': {
headers: {
Authorization: 'Bearer supersecret',
},
},
},
},
@@ -141,9 +172,11 @@ describe('createRouter', () => {
},
// no target would cause the buildMiddleware to fail
proxy: {
'/test': {
headers: {
Authorization: 'Bearer supersecret',
endpoints: {
'/test': {
headers: {
Authorization: 'Bearer supersecret',
},
},
},
},
+48 -5
View File
@@ -191,6 +191,31 @@ export function buildMiddleware(
return createProxyMiddleware(filter, fullConfig);
}
function readProxyConfig(config: Config, logger: Logger): unknown {
const endpoints = config.getOptionalConfig('proxy.endpoints')?.get();
if (endpoints) {
return endpoints;
}
const root = config.getOptionalConfig('proxy')?.get();
if (!root) {
return {};
}
const rootEndpoints = Object.fromEntries(
Object.entries(root).filter(([key]) => key.startsWith('/')),
);
if (Object.keys(rootEndpoints).length === 0) {
return {};
}
logger.warn(
"Configuring proxy endpoints in the root 'proxy' configuration is deprecated. Move this configuration to 'proxy.endpoints' instead.",
);
return rootEndpoints;
}
/**
* Creates a new {@link https://expressjs.com/en/api.html#router | "express router"} that proxy each target configured under the `proxy` key of the config
* @example
@@ -215,25 +240,39 @@ export async function createRouter(
const router = Router();
let currentRouter = Router();
const skipInvalidProxies =
options.skipInvalidProxies ??
options.config.getOptionalBoolean('proxy.skipInvalidProxies') ??
false;
const reviveConsumedRequestBodies =
options.reviveConsumedRequestBodies ??
options.config.getOptionalBoolean('proxy.reviveConsumedRequestBodies') ??
false;
const proxyOptions = {
skipInvalidProxies,
reviveConsumedRequestBodies,
logger: options.logger,
};
const externalUrl = await options.discovery.getExternalBaseUrl('proxy');
const { pathname: pathPrefix } = new URL(externalUrl);
const proxyConfig = options.config.getOptional('proxy') ?? {};
configureMiddlewares(options, currentRouter, pathPrefix, proxyConfig);
const proxyConfig = readProxyConfig(options.config, options.logger);
configureMiddlewares(proxyOptions, currentRouter, pathPrefix, proxyConfig);
router.use((...args) => currentRouter(...args));
if (options.config.subscribe) {
let currentKey = JSON.stringify(proxyConfig);
options.config.subscribe(() => {
const newProxyConfig = options.config.getOptional('proxy') ?? {};
const newProxyConfig = readProxyConfig(options.config, options.logger);
const newKey = JSON.stringify(newProxyConfig);
if (currentKey !== newKey) {
currentKey = newKey;
currentRouter = Router();
configureMiddlewares(
options,
proxyOptions,
currentRouter,
pathPrefix,
newProxyConfig,
@@ -246,7 +285,11 @@ export async function createRouter(
}
function configureMiddlewares(
options: RouterOptions,
options: {
reviveConsumedRequestBodies: boolean;
skipInvalidProxies: boolean;
logger: Logger;
},
router: express.Router,
pathPrefix: string,
proxyConfig: any,