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:
Patrick Jungermann
2022-10-04 16:41:08 +02:00
parent 7bbd2403a1
commit dc9da28abd
24 changed files with 918 additions and 11 deletions
+17
View File
@@ -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.
+86
View File
@@ -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,
});
},
});
},
});
```
+18
View File
@@ -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>;
}
```
+28
View File
@@ -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[];
};
};
}
+10 -2
View File
@@ -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"
}
+1
View File
@@ -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';
@@ -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';
+30
View File
@@ -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;
}
+18
View File
@@ -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';
+1
View File
@@ -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';
+8 -1
View File
@@ -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;
}
/**
+5
View File
@@ -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