feat(events/http): add HTTP endpoint-based event publisher
This plugin adds an event publisher which receives events via (an) HTTP endpoint(s) and can be used as destination at webhook subscriptions. Relates-to: #11082 Signed-off-by: Patrick Jungermann <Patrick.Jungermann@gmail.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
---
|
||||
'@backstage/plugin-events-backend': minor
|
||||
'@backstage/plugin-events-node': minor
|
||||
---
|
||||
|
||||
Support events received via HTTP endpoints at plugin-events-backend.
|
||||
|
||||
The plugin provides an event publisher `HttpPostIngressEventPublisher`
|
||||
which will allow you to receive events via
|
||||
HTTP endpoints `POST /api/events/http/{topic}`
|
||||
and will publish these to the used event broker.
|
||||
|
||||
Using a provided custom validator, you can participate in the decision
|
||||
which events are accepted, e.g. by verifying the source of the request.
|
||||
|
||||
Please find more information at
|
||||
https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md.
|
||||
@@ -13,6 +13,10 @@ implementation of your choice as you need (e.g., via module).
|
||||
Some of these (non-exhaustive) may provide added persistence,
|
||||
or use external systems like AWS EventBridge, AWS SNS, Kafka, etc.
|
||||
|
||||
By default, the plugin ships with support to receive events via HTTP endpoints
|
||||
`POST /api/events/http/{topic}` and will publish these
|
||||
to the used event broker.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
@@ -81,6 +85,36 @@ yarn add --cwd packages/backend @backstage/plugin-events-backend
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
In order to create HTTP endpoints to receive events for a certain
|
||||
topic, you need to add them at your configuration:
|
||||
|
||||
```yaml
|
||||
events:
|
||||
http:
|
||||
topics:
|
||||
- bitbucketCloud
|
||||
- github
|
||||
- whatever
|
||||
```
|
||||
|
||||
Only those topics added to the configuration will result in
|
||||
available endpoints.
|
||||
|
||||
The example above would result in the following endpoints:
|
||||
|
||||
```
|
||||
POST /api/events/http/bitbucketCloud
|
||||
POST /api/events/http/github
|
||||
POST /api/events/http/whatever
|
||||
```
|
||||
|
||||
You may want to use these for webhooks by SCM providers
|
||||
in combination with suitable event subscribers.
|
||||
|
||||
However, it is not limited to these use cases.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Custom Event Broker
|
||||
@@ -122,3 +156,55 @@ export const yourModuleEventsModule = createBackendModule({
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Request Validator
|
||||
|
||||
Example using the `EventsBackend`:
|
||||
|
||||
```ts
|
||||
const http = HttpPostIngressEventPublisher.fromConfig({
|
||||
config: env.config,
|
||||
ingresses: {
|
||||
yourTopic: {
|
||||
validator: yourValidator,
|
||||
},
|
||||
},
|
||||
logger: env.logger,
|
||||
router: httpRouter,
|
||||
});
|
||||
|
||||
await new EventsBackend(env.logger)
|
||||
.addPublishers(http)
|
||||
// [...]
|
||||
.start();
|
||||
```
|
||||
|
||||
Example using a module:
|
||||
|
||||
```ts
|
||||
import { eventsExtensionPoint } from '@backstage/plugin-events-node';
|
||||
|
||||
// [...]
|
||||
|
||||
export const yourModuleEventsModule = createBackendModule({
|
||||
pluginId: 'events',
|
||||
moduleId: 'yourModule',
|
||||
register(env) {
|
||||
// [...]
|
||||
env.registerInit({
|
||||
deps: {
|
||||
// [...]
|
||||
events: eventsExtensionPoint,
|
||||
// [...]
|
||||
},
|
||||
async init({ /* ... */ events /*, ... */ }) {
|
||||
// [...]
|
||||
events.addHttpPostIngress({
|
||||
topic: 'your-topic',
|
||||
validator: yourValidator,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
|
||||
```ts
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { EventBroker } from '@backstage/plugin-events-node';
|
||||
import { EventPublisher } from '@backstage/plugin-events-node';
|
||||
import { EventSubscriber } from '@backstage/plugin-events-node';
|
||||
import express from 'express';
|
||||
import { HttpPostIngressOptions } from '@backstage/plugin-events-node';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
// @public
|
||||
@@ -27,4 +30,19 @@ export class EventsBackend {
|
||||
|
||||
// @alpha
|
||||
export const eventsPlugin: (options?: undefined) => BackendFeature;
|
||||
|
||||
// @public
|
||||
export class HttpPostIngressEventPublisher implements EventPublisher {
|
||||
// (undocumented)
|
||||
static fromConfig(env: {
|
||||
config: Config;
|
||||
ingresses?: {
|
||||
[topic: string]: Omit<HttpPostIngressOptions, 'topic'>;
|
||||
};
|
||||
logger: Logger;
|
||||
router: express.Router;
|
||||
}): HttpPostIngressEventPublisher;
|
||||
// (undocumented)
|
||||
setEventBroker(eventBroker: EventBroker): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2022 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 interface Config {
|
||||
events?: {
|
||||
http?: {
|
||||
/**
|
||||
* Topics for which a route has to be registered
|
||||
* at which we can receive events via HTTP POST requests
|
||||
* (i.e. received from webhooks).
|
||||
*/
|
||||
topics?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -23,18 +23,26 @@
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/plugin-events-node": "workspace:^",
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/plugin-events-backend-test-utils": "workspace:^"
|
||||
"@backstage/plugin-events-backend-test-utils": "workspace:^",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"alpha",
|
||||
"config.d.ts",
|
||||
"dist"
|
||||
]
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
|
||||
@@ -22,3 +22,4 @@
|
||||
|
||||
export { EventsBackend } from './service/EventsBackend';
|
||||
export { eventsPlugin } from './service/EventsPlugin';
|
||||
export { HttpPostIngressEventPublisher } from './service/http';
|
||||
|
||||
@@ -14,9 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { errorHandler, getVoidLogger } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
configServiceRef,
|
||||
createBackendModule,
|
||||
httpRouterServiceRef,
|
||||
loggerServiceRef,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { startTestBackend } from '@backstage/backend-test-utils';
|
||||
@@ -26,13 +29,29 @@ import {
|
||||
TestEventPublisher,
|
||||
TestEventSubscriber,
|
||||
} from '@backstage/plugin-events-backend-test-utils';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import request from 'supertest';
|
||||
import { eventsPlugin } from './EventsPlugin';
|
||||
|
||||
describe('eventPlugin', () => {
|
||||
it('should be initialized properly', async () => {
|
||||
const eventBroker = new TestEventBroker();
|
||||
const publisher = new TestEventPublisher();
|
||||
const subscriber = new TestEventSubscriber('sub', ['topicA']);
|
||||
const subscriber = new TestEventSubscriber('sub', ['fake']);
|
||||
|
||||
const config = new ConfigReader({
|
||||
events: {
|
||||
http: {
|
||||
topics: ['fake'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const httpRouter = Router();
|
||||
httpRouter.use(express.json());
|
||||
httpRouter.use(errorHandler());
|
||||
const app = express().use(httpRouter);
|
||||
|
||||
const testModule = createBackendModule({
|
||||
pluginId: 'events',
|
||||
@@ -53,12 +72,26 @@ describe('eventPlugin', () => {
|
||||
|
||||
await startTestBackend({
|
||||
extensionPoints: [],
|
||||
services: [[loggerServiceRef, getVoidLogger()]],
|
||||
services: [
|
||||
[configServiceRef, config],
|
||||
[httpRouterServiceRef, httpRouter],
|
||||
[loggerServiceRef, getVoidLogger()],
|
||||
],
|
||||
features: [eventsPlugin(), testModule()],
|
||||
});
|
||||
|
||||
expect(publisher.eventBroker).toBe(eventBroker);
|
||||
expect(eventBroker.subscribed.length).toEqual(1);
|
||||
expect(eventBroker.subscribed[0]).toBe(subscriber);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/http/fake')
|
||||
.timeout(100)
|
||||
.send({ test: 'fake' });
|
||||
expect(response.status).toBe(202);
|
||||
|
||||
expect(eventBroker.published.length).toEqual(1);
|
||||
expect(eventBroker.published[0].topic).toEqual('fake');
|
||||
expect(eventBroker.published[0].eventPayload).toEqual({ test: 'fake' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
configServiceRef,
|
||||
createBackendPlugin,
|
||||
httpRouterServiceRef,
|
||||
loggerServiceRef,
|
||||
loggerToWinstonLogger,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
@@ -25,11 +27,15 @@ import {
|
||||
EventSubscriber,
|
||||
eventsExtensionPoint,
|
||||
EventsExtensionPoint,
|
||||
HttpPostIngressOptions,
|
||||
} from '@backstage/plugin-events-node';
|
||||
import { InMemoryEventBroker } from './InMemoryEventBroker';
|
||||
import Router from 'express-promise-router';
|
||||
import { HttpPostIngressEventPublisher } from './http';
|
||||
|
||||
class EventsExtensionPointImpl implements EventsExtensionPoint {
|
||||
#eventBroker: EventBroker | undefined;
|
||||
#httpPostIngresses: HttpPostIngressOptions[] = [];
|
||||
#publishers: EventPublisher[] = [];
|
||||
#subscribers: EventSubscriber[] = [];
|
||||
|
||||
@@ -49,6 +55,10 @@ class EventsExtensionPointImpl implements EventsExtensionPoint {
|
||||
this.#subscribers.push(...subscribers.flat());
|
||||
}
|
||||
|
||||
addHttpPostIngress(options: HttpPostIngressOptions) {
|
||||
this.#httpPostIngresses.push(options);
|
||||
}
|
||||
|
||||
get eventBroker() {
|
||||
return this.#eventBroker;
|
||||
}
|
||||
@@ -60,6 +70,10 @@ class EventsExtensionPointImpl implements EventsExtensionPoint {
|
||||
get subscribers() {
|
||||
return this.#subscribers;
|
||||
}
|
||||
|
||||
get httpPostIngresses() {
|
||||
return this.#httpPostIngresses;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,18 +89,42 @@ export const eventsPlugin = createBackendPlugin({
|
||||
|
||||
env.registerInit({
|
||||
deps: {
|
||||
config: configServiceRef,
|
||||
httpRouter: httpRouterServiceRef,
|
||||
logger: loggerServiceRef,
|
||||
},
|
||||
async init({ logger }) {
|
||||
async init({ config, httpRouter, logger }) {
|
||||
const winstonLogger = loggerToWinstonLogger(logger);
|
||||
const eventsRouter = Router();
|
||||
const router = Router();
|
||||
eventsRouter.use('/http', router);
|
||||
|
||||
const ingresses = Object.fromEntries(
|
||||
extensionPoint.httpPostIngresses.map(ingress => [
|
||||
ingress.topic,
|
||||
ingress as Omit<HttpPostIngressOptions, 'topic'>,
|
||||
]),
|
||||
);
|
||||
|
||||
const http = HttpPostIngressEventPublisher.fromConfig({
|
||||
config,
|
||||
logger: winstonLogger,
|
||||
router,
|
||||
ingresses,
|
||||
});
|
||||
|
||||
if (!extensionPoint.eventBroker) {
|
||||
const winstonLogger = loggerToWinstonLogger(logger);
|
||||
extensionPoint.setEventBroker(new InMemoryEventBroker(winstonLogger));
|
||||
}
|
||||
|
||||
extensionPoint.eventBroker!.subscribe(extensionPoint.subscribers);
|
||||
extensionPoint.publishers.forEach(publisher =>
|
||||
publisher.setEventBroker(extensionPoint.eventBroker!),
|
||||
);
|
||||
[extensionPoint.publishers, http]
|
||||
.flat()
|
||||
.forEach(publisher =>
|
||||
publisher.setEventBroker(extensionPoint.eventBroker!),
|
||||
);
|
||||
|
||||
httpRouter.use(eventsRouter);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
import { errorHandler, getVoidLogger } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import request from 'supertest';
|
||||
import { HttpPostIngressEventPublisher } from './HttpPostIngressEventPublisher';
|
||||
|
||||
describe('HttpPostIngressEventPublisher', () => {
|
||||
const logger = getVoidLogger();
|
||||
|
||||
it('should set up routes correctly', async () => {
|
||||
const config = new ConfigReader({
|
||||
events: {
|
||||
http: {
|
||||
topics: ['testA'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
router.use(errorHandler());
|
||||
const app = express().use(router);
|
||||
|
||||
const publisher = HttpPostIngressEventPublisher.fromConfig({
|
||||
config,
|
||||
logger,
|
||||
router,
|
||||
ingresses: {
|
||||
testB: {},
|
||||
},
|
||||
});
|
||||
|
||||
const eventBroker = new TestEventBroker();
|
||||
await publisher.setEventBroker(eventBroker);
|
||||
|
||||
const notFoundResponse = await request(app)
|
||||
.post('/unknown')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(notFoundResponse.status).toBe(404);
|
||||
|
||||
const response1 = await request(app)
|
||||
.post('/testA')
|
||||
.set('X-Custom-Header', 'test-value')
|
||||
.timeout(100)
|
||||
.send({ testA: 'data' });
|
||||
expect(response1.status).toBe(202);
|
||||
|
||||
const response2 = await request(app)
|
||||
.post('/testB')
|
||||
.set('X-Custom-Header', 'test-value')
|
||||
.timeout(100)
|
||||
.send({ testB: 'data' });
|
||||
expect(response2.status).toBe(202);
|
||||
|
||||
expect(eventBroker.published.length).toEqual(2);
|
||||
expect(eventBroker.published[0].topic).toEqual('testA');
|
||||
expect(eventBroker.published[0].eventPayload).toEqual({ testA: 'data' });
|
||||
expect(eventBroker.published[0].metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
'content-type': 'application/json',
|
||||
'x-custom-header': 'test-value',
|
||||
}),
|
||||
);
|
||||
expect(eventBroker.published[1].topic).toEqual('testB');
|
||||
expect(eventBroker.published[1].eventPayload).toEqual({ testB: 'data' });
|
||||
expect(eventBroker.published[1].metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
'content-type': 'application/json',
|
||||
'x-custom-header': 'test-value',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('with validator', async () => {
|
||||
const config = new ConfigReader({
|
||||
events: {
|
||||
http: {
|
||||
topics: ['testA'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
router.use(errorHandler());
|
||||
const app = express().use(router);
|
||||
|
||||
const publisher = HttpPostIngressEventPublisher.fromConfig({
|
||||
config,
|
||||
logger,
|
||||
router,
|
||||
ingresses: {
|
||||
testB: {
|
||||
validator: async (req, context) => {
|
||||
if (req.headers['x-test-signature'] === 'testB-signature') {
|
||||
return;
|
||||
}
|
||||
|
||||
context.reject({
|
||||
status: 400,
|
||||
payload: {
|
||||
message: 'wrong signature',
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
testC: {
|
||||
validator: async (req, context) => {
|
||||
if (req.headers['x-test-signature'] === 'testC-signature') {
|
||||
return;
|
||||
}
|
||||
|
||||
context.reject({
|
||||
status: 404,
|
||||
// payload: {},
|
||||
});
|
||||
},
|
||||
},
|
||||
testD: {
|
||||
validator: async (req, context) => {
|
||||
if (req.headers['x-test-signature'] === 'testD-signature') {
|
||||
return;
|
||||
}
|
||||
|
||||
context.reject({
|
||||
// status: 403,
|
||||
// payload: {},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const eventBroker = new TestEventBroker();
|
||||
await publisher.setEventBroker(eventBroker);
|
||||
|
||||
const response1 = await request(app)
|
||||
.post('/testA')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(response1.status).toBe(202);
|
||||
|
||||
const response2 = await request(app)
|
||||
.post('/testB')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(response2.status).toBe(400);
|
||||
expect(response2.body).toEqual({ message: 'wrong signature' });
|
||||
|
||||
const response3 = await request(app)
|
||||
.post('/testB')
|
||||
.set('X-Test-Signature', 'wrong')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(response3.status).toBe(400);
|
||||
expect(response3.body).toEqual({ message: 'wrong signature' });
|
||||
|
||||
const response4 = await request(app)
|
||||
.post('/testB')
|
||||
.set('X-Test-Signature', 'testB-signature')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(response4.status).toBe(202);
|
||||
|
||||
const response5 = await request(app)
|
||||
.post('/testC')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(response5.status).toBe(404);
|
||||
expect(response5.body).toEqual({});
|
||||
|
||||
const response6 = await request(app)
|
||||
.post('/testD')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(response6.status).toBe(403);
|
||||
expect(response6.body).toEqual({});
|
||||
|
||||
expect(eventBroker.published.length).toEqual(2);
|
||||
expect(eventBroker.published[0].topic).toEqual('testA');
|
||||
expect(eventBroker.published[0].eventPayload).toEqual({ test: 'data' });
|
||||
expect(eventBroker.published[1].topic).toEqual('testB');
|
||||
expect(eventBroker.published[1].eventPayload).toEqual({ test: 'data' });
|
||||
expect(eventBroker.published[1].metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
'x-test-signature': 'testB-signature',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('without configuration', async () => {
|
||||
const config = new ConfigReader({});
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
router.use(errorHandler());
|
||||
|
||||
expect(() =>
|
||||
HttpPostIngressEventPublisher.fromConfig({
|
||||
config,
|
||||
logger,
|
||||
router,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
EventBroker,
|
||||
EventPublisher,
|
||||
HttpPostIngressOptions,
|
||||
RequestValidator,
|
||||
} from '@backstage/plugin-events-node';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import { RequestValidationContextImpl } from './validation';
|
||||
|
||||
/**
|
||||
* Publishes events received from their origin (e.g., webhook events from an SCM system)
|
||||
* via HTTP POST endpoint and passes the request body as event payload to the registered subscribers.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
// TODO(pjungermann): add prom metrics? (see plugins/catalog-backend/src/util/metrics.ts, etc.)
|
||||
export class HttpPostIngressEventPublisher implements EventPublisher {
|
||||
private eventBroker?: EventBroker;
|
||||
|
||||
static fromConfig(env: {
|
||||
config: Config;
|
||||
ingresses?: { [topic: string]: Omit<HttpPostIngressOptions, 'topic'> };
|
||||
logger: Logger;
|
||||
router: express.Router;
|
||||
}): HttpPostIngressEventPublisher {
|
||||
const topics =
|
||||
env.config.getOptionalStringArray('events.http.topics') ?? [];
|
||||
|
||||
const ingresses = env.ingresses ?? {};
|
||||
topics.forEach(topic => {
|
||||
// don't overwrite topic settings
|
||||
// (e.g., added at the config as well as argument)
|
||||
if (!ingresses[topic]) {
|
||||
ingresses[topic] = {};
|
||||
}
|
||||
});
|
||||
|
||||
return new HttpPostIngressEventPublisher(env.logger, env.router, ingresses);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private logger: Logger,
|
||||
router: express.Router,
|
||||
ingresses: { [topic: string]: Omit<HttpPostIngressOptions, 'topic'> },
|
||||
) {
|
||||
router.use(this.createRouter(ingresses));
|
||||
}
|
||||
|
||||
async setEventBroker(eventBroker: EventBroker): Promise<void> {
|
||||
this.eventBroker = eventBroker;
|
||||
}
|
||||
|
||||
private createRouter(ingresses: {
|
||||
[topic: string]: Omit<HttpPostIngressOptions, 'topic'>;
|
||||
}): express.Router {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
Object.keys(ingresses).forEach(topic =>
|
||||
this.addRouteForTopic(router, topic, ingresses[topic].validator),
|
||||
);
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
|
||||
private addRouteForTopic(
|
||||
router: express.Router,
|
||||
topic: string,
|
||||
validator?: RequestValidator,
|
||||
): void {
|
||||
const path = `/${topic}`;
|
||||
|
||||
router.post(path, async (request, response) => {
|
||||
const context = new RequestValidationContextImpl();
|
||||
await validator?.(request, context);
|
||||
if (context.wasRejected()) {
|
||||
response
|
||||
.status(context.rejectionDetails!.status)
|
||||
.json(context.rejectionDetails!.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventPayload = request.body;
|
||||
await this.eventBroker!.publish({
|
||||
topic,
|
||||
eventPayload,
|
||||
metadata: request.headers,
|
||||
});
|
||||
|
||||
response.status(202).json({ status: 'accepted' });
|
||||
});
|
||||
|
||||
// TODO(pjungermann): We don't really know the externally defined path prefix here,
|
||||
// however it is more useful for users to have it. Is there a better way?
|
||||
this.logger.info(`Registered /api/events/http${path} to receive events`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 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 { HttpPostIngressEventPublisher } from './HttpPostIngressEventPublisher';
|
||||
export * from './validation';
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
import { RequestValidationContextImpl } from './RequestValidationContextImpl';
|
||||
|
||||
describe('RequestValidationContextImpl', () => {
|
||||
it('not rejected', () => {
|
||||
const context = new RequestValidationContextImpl();
|
||||
|
||||
expect(context.wasRejected()).toBe(false);
|
||||
expect(context.rejectionDetails).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reject without details', () => {
|
||||
const context = new RequestValidationContextImpl();
|
||||
|
||||
context.reject();
|
||||
|
||||
expect(context.wasRejected()).toBe(true);
|
||||
expect(context.rejectionDetails).not.toBeUndefined();
|
||||
expect(context.rejectionDetails!.status).toBe(403);
|
||||
expect(context.rejectionDetails!.payload).toEqual({});
|
||||
});
|
||||
|
||||
it('reject with partial details', () => {
|
||||
const context = new RequestValidationContextImpl();
|
||||
|
||||
context.reject({ status: 404 });
|
||||
|
||||
expect(context.wasRejected()).toBe(true);
|
||||
expect(context.rejectionDetails).not.toBeUndefined();
|
||||
expect(context.rejectionDetails!.status).toBe(404);
|
||||
expect(context.rejectionDetails!.payload).toEqual({});
|
||||
});
|
||||
|
||||
it('reject with details', () => {
|
||||
const context = new RequestValidationContextImpl();
|
||||
|
||||
context.reject({
|
||||
status: 403,
|
||||
payload: { message: 'invalid signature' },
|
||||
});
|
||||
|
||||
expect(context.wasRejected()).toBe(true);
|
||||
expect(context.rejectionDetails).not.toBeUndefined();
|
||||
expect(context.rejectionDetails!.status).toBe(403);
|
||||
expect(context.rejectionDetails!.payload).toEqual({
|
||||
message: 'invalid signature',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
RequestRejectionDetails,
|
||||
RequestValidationContext,
|
||||
} from '@backstage/plugin-events-node';
|
||||
|
||||
export class RequestValidationContextImpl implements RequestValidationContext {
|
||||
#rejectionDetails: RequestRejectionDetails | undefined;
|
||||
|
||||
reject(details?: Partial<RequestRejectionDetails>): void {
|
||||
this.#rejectionDetails = {
|
||||
status: details?.status ?? 403,
|
||||
payload: details?.payload ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
wasRejected(): boolean {
|
||||
return this.#rejectionDetails !== undefined;
|
||||
}
|
||||
|
||||
get rejectionDetails() {
|
||||
return this.#rejectionDetails;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 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 { RequestValidationContextImpl } from './RequestValidationContextImpl';
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
```ts
|
||||
import { ExtensionPoint } from '@backstage/backend-plugin-api';
|
||||
import { Request as Request_2 } from 'express';
|
||||
|
||||
// @public
|
||||
export interface EventBroker {
|
||||
@@ -42,6 +43,8 @@ export abstract class EventRouter implements EventPublisher, EventSubscriber {
|
||||
|
||||
// @alpha (undocumented)
|
||||
export interface EventsExtensionPoint {
|
||||
// (undocumented)
|
||||
addHttpPostIngress(options: HttpPostIngressOptions): void;
|
||||
// (undocumented)
|
||||
addPublishers(
|
||||
...publishers: Array<EventPublisher | Array<EventPublisher>>
|
||||
@@ -63,6 +66,33 @@ export interface EventSubscriber {
|
||||
supportsEventTopics(): string[];
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface HttpPostIngressOptions {
|
||||
// (undocumented)
|
||||
topic: string;
|
||||
// (undocumented)
|
||||
validator?: RequestValidator;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface RequestRejectionDetails {
|
||||
// (undocumented)
|
||||
payload: unknown;
|
||||
// (undocumented)
|
||||
status: number;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface RequestValidationContext {
|
||||
reject(details?: Partial<RequestRejectionDetails>): void;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type RequestValidator = (
|
||||
request: Request_2,
|
||||
context: RequestValidationContext,
|
||||
) => Promise<void>;
|
||||
|
||||
// @public
|
||||
export abstract class SubTopicEventRouter extends EventRouter {
|
||||
protected constructor(topic: string);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
import { RequestValidator } from './validation';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface HttpPostIngressOptions {
|
||||
topic: string;
|
||||
validator?: RequestValidator;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2022 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 type { HttpPostIngressOptions } from './HttpPostIngressOptions';
|
||||
export * from './validation';
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Details for how to respond to the rejection
|
||||
* of the received HTTP request transmitting an event payload.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface RequestRejectionDetails {
|
||||
status: number;
|
||||
payload: unknown;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
import { RequestRejectionDetails } from './RequestRejectionDetails';
|
||||
|
||||
/**
|
||||
* Passed context for the validation
|
||||
* at which rejections can be expressed.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface RequestValidationContext {
|
||||
/**
|
||||
* Rejects the validated request
|
||||
*
|
||||
* @param details - Optional details about the rejection which will be provided to the sender.
|
||||
*/
|
||||
reject(details?: Partial<RequestRejectionDetails>): void;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
import { Request } from 'express';
|
||||
import { RequestValidationContext } from './RequestValidationContext';
|
||||
|
||||
/**
|
||||
* Validator used to check the received HTTP request
|
||||
* transmitting an event payload.
|
||||
*
|
||||
* E.g., it can be used for signature verification like
|
||||
* for GitHub webhook events
|
||||
* (https://docs.github.com/en/developers/webhooks-and-events/webhooks/creating-webhooks#secret)
|
||||
* or other kinds of checks.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type RequestValidator = (
|
||||
request: Request,
|
||||
context: RequestValidationContext,
|
||||
) => Promise<void>;
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2022 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 type { RequestRejectionDetails } from './RequestRejectionDetails';
|
||||
export type { RequestValidationContext } from './RequestValidationContext';
|
||||
export type { RequestValidator } from './RequestValidator';
|
||||
@@ -19,4 +19,5 @@ export type { EventParams } from './EventParams';
|
||||
export type { EventPublisher } from './EventPublisher';
|
||||
export { EventRouter } from './EventRouter';
|
||||
export type { EventSubscriber } from './EventSubscriber';
|
||||
export * from './http';
|
||||
export { SubTopicEventRouter } from './SubTopicEventRouter';
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
*/
|
||||
|
||||
import { createExtensionPoint } from '@backstage/backend-plugin-api';
|
||||
import { EventBroker, EventPublisher, EventSubscriber } from './api';
|
||||
import {
|
||||
EventBroker,
|
||||
EventPublisher,
|
||||
EventSubscriber,
|
||||
HttpPostIngressOptions,
|
||||
} from './api';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
@@ -30,6 +35,8 @@ export interface EventsExtensionPoint {
|
||||
addSubscribers(
|
||||
...subscribers: Array<EventSubscriber | Array<EventSubscriber>>
|
||||
): void;
|
||||
|
||||
addHttpPostIngress(options: HttpPostIngressOptions): void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5549,8 +5549,13 @@ __metadata:
|
||||
"@backstage/backend-plugin-api": "workspace:^"
|
||||
"@backstage/backend-test-utils": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/plugin-events-backend-test-utils": "workspace:^"
|
||||
"@backstage/plugin-events-node": "workspace:^"
|
||||
"@types/express": ^4.17.6
|
||||
express: ^4.17.1
|
||||
express-promise-router: ^4.1.0
|
||||
supertest: ^6.1.3
|
||||
winston: ^3.2.1
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
Reference in New Issue
Block a user