From b95aa77ce2f9349007d88d96c5a6943561e7051e Mon Sep 17 00:00:00 2001 From: Rogerio Angeliski Date: Wed, 12 Feb 2025 22:25:37 -0300 Subject: [PATCH 1/4] feat(events-backend,events-node): add a parser option to events by content type Signed-off-by: Rogerio Angeliski --- .changeset/ten-impalas-hope.md | 6 ++ plugins/events-backend/README.md | 42 +++++++++ plugins/events-backend/report.api.md | 4 + .../src/service/EventsPlugin.ts | 20 ++++- .../HttpPostIngressEventPublisher.test.ts | 87 ++++++++++++++++++- .../http/HttpPostIngressEventPublisher.ts | 69 +++++---------- .../HttpApplicationJsonBodyParser.ts | 45 ++++++++++ .../src/service/http/body-parser/index.ts | 22 +++++ .../events-backend/src/service/http/errors.ts | 38 ++++++++ plugins/events-node/package.json | 2 + plugins/events-node/report-alpha.api.md | 3 + plugins/events-node/report.api.md | 22 +++++ .../src/api/http/HttpBodyParserOptions.ts | 24 +++++ .../api/http/body-parser/HttpBodyParser.ts | 33 +++++++ .../src/api/http/body-parser/index.ts | 16 ++++ plugins/events-node/src/api/http/index.ts | 2 + plugins/events-node/src/extensions.ts | 3 + yarn.lock | 2 + 18 files changed, 393 insertions(+), 47 deletions(-) create mode 100644 .changeset/ten-impalas-hope.md create mode 100644 plugins/events-backend/src/service/http/body-parser/HttpApplicationJsonBodyParser.ts create mode 100644 plugins/events-backend/src/service/http/body-parser/index.ts create mode 100644 plugins/events-backend/src/service/http/errors.ts create mode 100644 plugins/events-node/src/api/http/HttpBodyParserOptions.ts create mode 100644 plugins/events-node/src/api/http/body-parser/HttpBodyParser.ts create mode 100644 plugins/events-node/src/api/http/body-parser/index.ts diff --git a/.changeset/ten-impalas-hope.md b/.changeset/ten-impalas-hope.md new file mode 100644 index 0000000000..b770b15c6b --- /dev/null +++ b/.changeset/ten-impalas-hope.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-events-backend': minor +'@backstage/plugin-events-node': minor +--- + +add `addHttpPostBodyParser` to events extension to allow body parse customization. This feature will enhance flexibility in handling HTTP POST requests in event-related operations. diff --git a/plugins/events-backend/README.md b/plugins/events-backend/README.md index 7f88452ff6..219b60f213 100644 --- a/plugins/events-backend/README.md +++ b/plugins/events-backend/README.md @@ -140,6 +140,48 @@ export const eventsModuleYourFeature = createBackendModule({ }); ``` +### Request Body Parse + +We need to parse the request body before we can validate it. We have some default parsers but you can provide your own when necessary. + +```ts +import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; + +// [...] + +export const eventsModuleYourFeature = createBackendModule({ + pluginId: 'events', + moduleId: 'your-feature', + register(env) { + // [...] + env.registerInit({ + deps: { + // [...] + events: eventsExtensionPoint, + // [...] + }, + async init({ /* ... */ events /*, ... */ }) { + // [...] + events.addHttpPostBodyParser({ + contentType: 'application/x-www-form-urlencoded', + parser: async (req, _topic) => { + return { + bodyParsed: req.body.toString('utf-8'), + bodyBuffer: req.body, + encoding: 'utf-8', + }; + }, + }); + }, + }); + }, +}); +``` + +We have the following default parsers: + +- `application/json` + #### Legacy Backend System ```ts diff --git a/plugins/events-backend/report.api.md b/plugins/events-backend/report.api.md index fb9f5fb237..286bc8317d 100644 --- a/plugins/events-backend/report.api.md +++ b/plugins/events-backend/report.api.md @@ -11,6 +11,7 @@ import { EventPublisher } from '@backstage/plugin-events-node'; import { EventsService } from '@backstage/plugin-events-node'; import { EventSubscriber } from '@backstage/plugin-events-node'; import express from 'express'; +import { HttpBodyParser } from '@backstage/plugin-events-node'; import { HttpPostIngressOptions } from '@backstage/plugin-events-node'; import { Logger } from 'winston'; import { LoggerService } from '@backstage/backend-plugin-api'; @@ -58,6 +59,9 @@ export class HttpPostIngressEventPublisher { ingresses?: { [topic: string]: Omit; }; + bodyParsers?: { + [contentType: string]: HttpBodyParser; + }; logger: LoggerService; }): HttpPostIngressEventPublisher; } diff --git a/plugins/events-backend/src/service/EventsPlugin.ts b/plugins/events-backend/src/service/EventsPlugin.ts index a837e69c32..1b43f0c527 100644 --- a/plugins/events-backend/src/service/EventsPlugin.ts +++ b/plugins/events-backend/src/service/EventsPlugin.ts @@ -24,6 +24,7 @@ import { } from '@backstage/plugin-events-node/alpha'; import { eventsServiceRef, + HttpBodyParserOptions, HttpPostIngressOptions, } from '@backstage/plugin-events-node'; import Router from 'express-promise-router'; @@ -31,7 +32,8 @@ import { HttpPostIngressEventPublisher } from './http'; import { createEventBusRouter } from './hub'; class EventsExtensionPointImpl implements EventsExtensionPoint { - #httpPostIngresses: HttpPostIngressOptions[] = []; + readonly #httpPostIngresses: HttpPostIngressOptions[] = []; + readonly #httpBodyParsers: HttpBodyParserOptions[] = []; setEventBroker(_: any): void { throw new Error( @@ -55,9 +57,17 @@ class EventsExtensionPointImpl implements EventsExtensionPoint { this.#httpPostIngresses.push(options); } + addHttpPostBodyParser(options: HttpBodyParserOptions): void { + this.#httpBodyParsers.push(options); + } + get httpPostIngresses() { return this.#httpPostIngresses; } + + get httpBodyParsers() { + return this.#httpBodyParsers; + } } /** @@ -99,10 +109,18 @@ export const eventsPlugin = createBackendPlugin({ ]), ); + const bodyParsers = Object.fromEntries( + extensionPoint.httpBodyParsers.map(option => [ + option.contentType, + option.parser, + ]), + ); + const http = HttpPostIngressEventPublisher.fromConfig({ config, events, ingresses, + bodyParsers, logger, }); const eventsRouter = Router(); diff --git a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts index 16f36d6b0a..84e2ed104c 100644 --- a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts +++ b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts @@ -22,6 +22,7 @@ import request from 'supertest'; import { HttpPostIngressEventPublisher } from './HttpPostIngressEventPublisher'; import { mockServices } from '@backstage/backend-test-utils'; import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; +import { HttpBodyParser } from '@backstage/plugin-events-node'; const middleware = MiddlewareFactory.create({ logger: mockServices.logger.mock(), @@ -207,7 +208,8 @@ describe('HttpPostIngressEventPublisher', () => { expect(response.body).toEqual( expect.objectContaining({ error: { - message: 'Unsupported media type: text/plain', + message: + 'Unsupported media type: text/plain. You need to provide a custom body parser for this media type using events extension.', name: 'UnsupportedMediaTypeError', statusCode: 415, }, @@ -217,6 +219,89 @@ describe('HttpPostIngressEventPublisher', () => { ); }); + it('with a text/plain body parser implementation', async () => { + const config = new ConfigReader({ + events: { + http: { + topics: ['testA'], + }, + }, + }); + + const router = Router(); + const app = express().use(router); + const events = new TestEventsService(); + + const bodyParser: HttpBodyParser = async (req, _topic) => { + return { + bodyParsed: req.body.toString('utf-8'), + bodyBuffer: req.body, + encoding: 'utf-8', + }; + }; + + const publisher = HttpPostIngressEventPublisher.fromConfig({ + config, + events, + bodyParsers: { + 'text/plain': bodyParser, + }, + logger, + }); + publisher.bind(router); + router.use(middleware.error()); + + const response = await request(app) + .post('/http/testA') + .type('text/plain') + .timeout(1000) + .send('Textual information'); + expect(response.status).toBe(202); + }); + + it('with a custom application/json body parser implementation', async () => { + const config = new ConfigReader({ + events: { + http: { + topics: ['testA'], + }, + }, + }); + + const router = Router(); + const app = express().use(router); + const events = new TestEventsService(); + const customParse = jest.fn(); + + const bodyParser: HttpBodyParser = async (req, _topic) => { + customParse(); + return { + bodyParsed: JSON.parse(req.body.toString('utf-8')), + bodyBuffer: req.body, + encoding: 'utf-8', + }; + }; + + const publisher = HttpPostIngressEventPublisher.fromConfig({ + config, + events, + bodyParsers: { + 'application/json': bodyParser, + }, + logger, + }); + publisher.bind(router); + router.use(middleware.error()); + + const response = await request(app) + .post('/http/testA') + .type('application/json') + .timeout(1000) + .send(JSON.stringify({ testA: 'data' })); + expect(response.status).toBe(202); + expect(customParse).toHaveBeenCalled(); + }); + it('with validator', async () => { const config = new ConfigReader({ events: { diff --git a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts index 806247e870..4ddcb2a291 100644 --- a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts +++ b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts @@ -16,35 +16,18 @@ import { LoggerService } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; -import { CustomErrorBase } from '@backstage/errors'; import { EventsService, + HttpBodyParser, HttpPostIngressOptions, RequestValidator, } from '@backstage/plugin-events-node'; import contentType from 'content-type'; import express from 'express'; import Router from 'express-promise-router'; +import { defaultHttpBodyParsers } from './body-parser'; import { RequestValidationContextImpl } from './validation'; - -class UnsupportedCharsetError extends CustomErrorBase { - name = 'UnsupportedCharsetError' as const; - statusCode = 415 as const; - - constructor(charset: string) { - super(`Unsupported charset: ${charset}`); - } -} - -class UnsupportedMediaTypeError extends CustomErrorBase { - name = 'UnsupportedMediaTypeError' as const; - statusCode = 415 as const; - - constructor(mediaType?: string) { - super(`Unsupported media type: ${mediaType ?? 'unknown'}`); - } -} - +import { UnsupportedMediaTypeError } from './errors'; /** * 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. @@ -57,6 +40,7 @@ export class HttpPostIngressEventPublisher { config: Config; events: EventsService; ingresses?: { [topic: string]: Omit }; + bodyParsers?: { [contentType: string]: HttpBodyParser }; logger: LoggerService; }): HttpPostIngressEventPublisher { const topics = @@ -71,7 +55,14 @@ export class HttpPostIngressEventPublisher { } }); - return new HttpPostIngressEventPublisher(env.events, env.logger, ingresses); + const parsers = { ...defaultHttpBodyParsers, ...env.bodyParsers }; + + return new HttpPostIngressEventPublisher( + env.events, + env.logger, + ingresses, + parsers, + ); } private constructor( @@ -80,6 +71,9 @@ export class HttpPostIngressEventPublisher { private readonly ingresses: { [topic: string]: Omit; }, + private readonly bodyParsers: { + [contentType: string]: HttpBodyParser; + }, ) {} bind(router: express.Router): void { @@ -108,32 +102,17 @@ export class HttpPostIngressEventPublisher { const logger = this.logger; router.post(path, async (request, response) => { - const requestBody = request.body; - if (!Buffer.isBuffer(requestBody)) { - throw new Error( - `Failed to retrieve raw body from incoming event for topic ${topic}; not a buffer: ${typeof requestBody}`, - ); + const requestContentType = contentType.parse(request); + const bodyParser = this.bodyParsers[requestContentType.type]; + + if (!bodyParser) { + throw new UnsupportedMediaTypeError(requestContentType.type); } - const bodyBuffer: Buffer = requestBody; - const parsedContentType = contentType.parse(request); - if ( - !parsedContentType.type || - parsedContentType.type !== 'application/json' - ) { - throw new UnsupportedMediaTypeError(parsedContentType.type); - } - - const encoding = parsedContentType.parameters.charset ?? 'utf-8'; - if (!Buffer.isEncoding(encoding)) { - throw new UnsupportedCharsetError(encoding); - } - - const bodyString = bodyBuffer.toString(encoding); - const bodyParsed = - parsedContentType.type === 'application/json' - ? JSON.parse(bodyString) - : bodyString; + const { bodyParsed, bodyBuffer, encoding } = await bodyParser( + request, + topic, + ); if (validator) { const requestDetails = { diff --git a/plugins/events-backend/src/service/http/body-parser/HttpApplicationJsonBodyParser.ts b/plugins/events-backend/src/service/http/body-parser/HttpApplicationJsonBodyParser.ts new file mode 100644 index 0000000000..b253d1babf --- /dev/null +++ b/plugins/events-backend/src/service/http/body-parser/HttpApplicationJsonBodyParser.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2025 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 { HttpBodyParser } from '@backstage/plugin-events-node'; +import contentType from 'content-type'; +import { UnsupportedCharsetError } from '../errors'; + +export const HttpApplicationJsonBodyParser: HttpBodyParser = async ( + request, + topic, +) => { + const requestBody = request.body; + if (!Buffer.isBuffer(requestBody)) { + throw new Error( + `Failed to retrieve raw body from incoming event for topic ${topic}; not a buffer: ${typeof requestBody}`, + ); + } + + const bodyBuffer: Buffer = requestBody; + const parsedContentType = contentType.parse(request); + + const encoding = parsedContentType.parameters.charset ?? 'utf-8'; + if (!Buffer.isEncoding(encoding)) { + throw new UnsupportedCharsetError(encoding); + } + + const bodyString = bodyBuffer.toString(encoding); + const bodyParsed = + parsedContentType.type === 'application/json' + ? JSON.parse(bodyString) + : bodyString; + return { bodyParsed, bodyBuffer, encoding }; +}; diff --git a/plugins/events-backend/src/service/http/body-parser/index.ts b/plugins/events-backend/src/service/http/body-parser/index.ts new file mode 100644 index 0000000000..7fc17ef235 --- /dev/null +++ b/plugins/events-backend/src/service/http/body-parser/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2025 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 { HttpBodyParser } from '@backstage/plugin-events-node'; +import { HttpApplicationJsonBodyParser } from './HttpApplicationJsonBodyParser'; + +export const defaultHttpBodyParsers: { [contentType: string]: HttpBodyParser } = + { + 'application/json': HttpApplicationJsonBodyParser, + }; diff --git a/plugins/events-backend/src/service/http/errors.ts b/plugins/events-backend/src/service/http/errors.ts new file mode 100644 index 0000000000..b078f0d40e --- /dev/null +++ b/plugins/events-backend/src/service/http/errors.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2025 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 { CustomErrorBase } from '@backstage/errors'; + +export class UnsupportedCharsetError extends CustomErrorBase { + name = 'UnsupportedCharsetError' as const; + statusCode = 415 as const; + + constructor(charset: string) { + super(`Unsupported charset: ${charset}`); + } +} + +export class UnsupportedMediaTypeError extends CustomErrorBase { + name = 'UnsupportedMediaTypeError' as const; + statusCode = 415 as const; + + constructor(mediaType?: string) { + super( + `Unsupported media type: ${ + mediaType ?? 'unknown' + }. You need to provide a custom body parser for this media type using events extension.`, + ); + } +} diff --git a/plugins/events-node/package.json b/plugins/events-node/package.json index 3d859f49a0..068c0d55f9 100644 --- a/plugins/events-node/package.json +++ b/plugins/events-node/package.json @@ -55,7 +55,9 @@ "@backstage/backend-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", + "@types/express": "^4.17.6", "cross-fetch": "^4.0.0", + "express": "^4.17.1", "uri-template": "^2.0.0" }, "devDependencies": { diff --git a/plugins/events-node/report-alpha.api.md b/plugins/events-node/report-alpha.api.md index f61048ac94..ba8b7e9adf 100644 --- a/plugins/events-node/report-alpha.api.md +++ b/plugins/events-node/report-alpha.api.md @@ -7,10 +7,13 @@ import { EventBroker } from '@backstage/plugin-events-node'; import { EventPublisher } from '@backstage/plugin-events-node'; import { EventSubscriber } from '@backstage/plugin-events-node'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { HttpBodyParserOptions } from '@backstage/plugin-events-node'; import { HttpPostIngressOptions } from '@backstage/plugin-events-node'; // @alpha (undocumented) export interface EventsExtensionPoint { + // (undocumented) + addHttpPostBodyParser(options: HttpBodyParserOptions): void; // (undocumented) addHttpPostIngress(options: HttpPostIngressOptions): void; // @deprecated (undocumented) diff --git a/plugins/events-node/report.api.md b/plugins/events-node/report.api.md index f53bb251ec..d68e321e8f 100644 --- a/plugins/events-node/report.api.md +++ b/plugins/events-node/report.api.md @@ -9,6 +9,7 @@ import { AuthService } from '@backstage/backend-plugin-api'; import { DiscoveryService } from '@backstage/backend-plugin-api'; import { LifecycleService } from '@backstage/backend-plugin-api'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { Request as Request_2 } from 'express'; import { RootConfigService } from '@backstage/backend-plugin-api'; import { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; @@ -111,6 +112,27 @@ export interface EventSubscriber { supportsEventTopics(): string[]; } +// @public (undocumented) +export type HttpBodyParsed = { + bodyParsed: any; + bodyBuffer: Buffer; + encoding: string; +}; + +// @public (undocumented) +export type HttpBodyParser = ( + request: Request_2, + topic: string, +) => Promise; + +// @public (undocumented) +export interface HttpBodyParserOptions { + // (undocumented) + contentType: string; + // (undocumented) + parser: HttpBodyParser; +} + // @public (undocumented) export interface HttpPostIngressOptions { // (undocumented) diff --git a/plugins/events-node/src/api/http/HttpBodyParserOptions.ts b/plugins/events-node/src/api/http/HttpBodyParserOptions.ts new file mode 100644 index 0000000000..cedaecf28e --- /dev/null +++ b/plugins/events-node/src/api/http/HttpBodyParserOptions.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2025 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 { HttpBodyParser } from './body-parser'; + +/** + * @public + */ +export interface HttpBodyParserOptions { + contentType: string; + parser: HttpBodyParser; +} diff --git a/plugins/events-node/src/api/http/body-parser/HttpBodyParser.ts b/plugins/events-node/src/api/http/body-parser/HttpBodyParser.ts new file mode 100644 index 0000000000..cfd65a6871 --- /dev/null +++ b/plugins/events-node/src/api/http/body-parser/HttpBodyParser.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2025 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'; +/** + * @public + */ +export type HttpBodyParsed = { + bodyParsed: any; + bodyBuffer: Buffer; + encoding: string; +}; + +/** + * @public + */ +export type HttpBodyParser = ( + request: Request, + topic: string, +) => Promise; diff --git a/plugins/events-node/src/api/http/body-parser/index.ts b/plugins/events-node/src/api/http/body-parser/index.ts new file mode 100644 index 0000000000..e1376841ae --- /dev/null +++ b/plugins/events-node/src/api/http/body-parser/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './HttpBodyParser'; diff --git a/plugins/events-node/src/api/http/index.ts b/plugins/events-node/src/api/http/index.ts index 9206819a4a..c0908583ad 100644 --- a/plugins/events-node/src/api/http/index.ts +++ b/plugins/events-node/src/api/http/index.ts @@ -15,4 +15,6 @@ */ export type { HttpPostIngressOptions } from './HttpPostIngressOptions'; +export type { HttpBodyParserOptions } from './HttpBodyParserOptions'; export * from './validation'; +export * from './body-parser'; diff --git a/plugins/events-node/src/extensions.ts b/plugins/events-node/src/extensions.ts index 90add52563..72be2db51f 100644 --- a/plugins/events-node/src/extensions.ts +++ b/plugins/events-node/src/extensions.ts @@ -19,6 +19,7 @@ import { EventBroker, EventPublisher, EventSubscriber, + HttpBodyParserOptions, HttpPostIngressOptions, } from '@backstage/plugin-events-node'; @@ -46,6 +47,8 @@ export interface EventsExtensionPoint { ): void; addHttpPostIngress(options: HttpPostIngressOptions): void; + + addHttpPostBodyParser(options: HttpBodyParserOptions): void; } /** diff --git a/yarn.lock b/yarn.lock index 8ae7f225e3..8d7ec6b3bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6727,7 +6727,9 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/types": "workspace:^" + "@types/express": ^4.17.6 cross-fetch: ^4.0.0 + express: ^4.17.1 msw: ^1.0.0 uri-template: ^2.0.0 languageName: unknown From 144661b05f6463a93abc9b7838df4777eb6dbe55 Mon Sep 17 00:00:00 2001 From: Rogerio Angeliski Date: Wed, 26 Feb 2025 20:16:47 -0300 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: Patrick Jungermann Signed-off-by: Rogerio Angeliski --- .changeset/ten-impalas-hope.md | 4 ++-- plugins/events-backend/src/service/http/errors.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/ten-impalas-hope.md b/.changeset/ten-impalas-hope.md index b770b15c6b..5bbdea7a11 100644 --- a/.changeset/ten-impalas-hope.md +++ b/.changeset/ten-impalas-hope.md @@ -1,6 +1,6 @@ --- -'@backstage/plugin-events-backend': minor -'@backstage/plugin-events-node': minor +'@backstage/plugin-events-backend': patch +'@backstage/plugin-events-node': patch --- add `addHttpPostBodyParser` to events extension to allow body parse customization. This feature will enhance flexibility in handling HTTP POST requests in event-related operations. diff --git a/plugins/events-backend/src/service/http/errors.ts b/plugins/events-backend/src/service/http/errors.ts index b078f0d40e..583f1a6b9b 100644 --- a/plugins/events-backend/src/service/http/errors.ts +++ b/plugins/events-backend/src/service/http/errors.ts @@ -32,7 +32,7 @@ export class UnsupportedMediaTypeError extends CustomErrorBase { super( `Unsupported media type: ${ mediaType ?? 'unknown' - }. You need to provide a custom body parser for this media type using events extension.`, + }. You need to provide a custom body parser for this media type using the EventsExtensionPoint.`, ); } } From cf75bca4f3ce4a4943ce346fc94d03555f54c4be Mon Sep 17 00:00:00 2001 From: Rogerio Angeliski Date: Wed, 26 Feb 2025 20:18:14 -0300 Subject: [PATCH 3/4] feat(events-backend,events-node): improve parser interface Signed-off-by: Rogerio Angeliski --- .../HttpPostIngressEventPublisher.test.ts | 49 ++++++++++++++++++- .../http/HttpPostIngressEventPublisher.ts | 3 +- .../HttpApplicationJsonBodyParser.ts | 7 ++- plugins/events-node/package.json | 1 + plugins/events-node/report.api.md | 2 + .../api/http/body-parser/HttpBodyParser.ts | 2 + yarn.lock | 1 + 7 files changed, 58 insertions(+), 7 deletions(-) diff --git a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts index 84e2ed104c..d0de341a6e 100644 --- a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts +++ b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts @@ -209,7 +209,7 @@ describe('HttpPostIngressEventPublisher', () => { expect.objectContaining({ error: { message: - 'Unsupported media type: text/plain. You need to provide a custom body parser for this media type using events extension.', + 'Unsupported media type: text/plain. You need to provide a custom body parser for this media type using the EventsExtensionPoint.', name: 'UnsupportedMediaTypeError', statusCode: 415, }, @@ -259,6 +259,47 @@ describe('HttpPostIngressEventPublisher', () => { expect(response.status).toBe(202); }); + it('with a invalid media type', async () => { + const config = new ConfigReader({ + events: { + http: { + topics: ['testA'], + }, + }, + }); + + const router = Router(); + const app = express().use(router); + const events = new TestEventsService(); + + const publisher = HttpPostIngressEventPublisher.fromConfig({ + config, + events, + logger, + }); + publisher.bind(router); + router.use(middleware.error()); + + const response = await request(app) + .post('/http/testA') + .type('not-valid-content/plain') + .timeout(1000) + .send('Textual information'); + expect(response.status).toBe(415); + expect(response.body).toEqual( + expect.objectContaining({ + error: { + message: + 'Unsupported media type: not-valid-content/plain. You need to provide a custom body parser for this media type using the EventsExtensionPoint.', + name: 'UnsupportedMediaTypeError', + statusCode: 415, + }, + request: { method: 'POST', url: '/http/testA' }, + response: { statusCode: 415 }, + }), + ); + }); + it('with a custom application/json body parser implementation', async () => { const config = new ConfigReader({ events: { @@ -273,7 +314,11 @@ describe('HttpPostIngressEventPublisher', () => { const events = new TestEventsService(); const customParse = jest.fn(); - const bodyParser: HttpBodyParser = async (req, _topic) => { + const bodyParser: HttpBodyParser = async ( + req, + _parsedMediaType, + _topic, + ) => { customParse(); return { bodyParsed: JSON.parse(req.body.toString('utf-8')), diff --git a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts index 4ddcb2a291..01d07e64d6 100644 --- a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts +++ b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts @@ -103,7 +103,7 @@ export class HttpPostIngressEventPublisher { router.post(path, async (request, response) => { const requestContentType = contentType.parse(request); - const bodyParser = this.bodyParsers[requestContentType.type]; + const bodyParser = this.bodyParsers[requestContentType.type ?? '']; if (!bodyParser) { throw new UnsupportedMediaTypeError(requestContentType.type); @@ -111,6 +111,7 @@ export class HttpPostIngressEventPublisher { const { bodyParsed, bodyBuffer, encoding } = await bodyParser( request, + requestContentType, topic, ); diff --git a/plugins/events-backend/src/service/http/body-parser/HttpApplicationJsonBodyParser.ts b/plugins/events-backend/src/service/http/body-parser/HttpApplicationJsonBodyParser.ts index b253d1babf..fa716a5db0 100644 --- a/plugins/events-backend/src/service/http/body-parser/HttpApplicationJsonBodyParser.ts +++ b/plugins/events-backend/src/service/http/body-parser/HttpApplicationJsonBodyParser.ts @@ -14,11 +14,11 @@ * limitations under the License. */ import { HttpBodyParser } from '@backstage/plugin-events-node'; -import contentType from 'content-type'; import { UnsupportedCharsetError } from '../errors'; export const HttpApplicationJsonBodyParser: HttpBodyParser = async ( request, + parsedMediaType, topic, ) => { const requestBody = request.body; @@ -29,16 +29,15 @@ export const HttpApplicationJsonBodyParser: HttpBodyParser = async ( } const bodyBuffer: Buffer = requestBody; - const parsedContentType = contentType.parse(request); - const encoding = parsedContentType.parameters.charset ?? 'utf-8'; + const encoding = parsedMediaType.parameters.charset ?? 'utf-8'; if (!Buffer.isEncoding(encoding)) { throw new UnsupportedCharsetError(encoding); } const bodyString = bodyBuffer.toString(encoding); const bodyParsed = - parsedContentType.type === 'application/json' + parsedMediaType.type === 'application/json' ? JSON.parse(bodyString) : bodyString; return { bodyParsed, bodyBuffer, encoding }; diff --git a/plugins/events-node/package.json b/plugins/events-node/package.json index 068c0d55f9..c98ae64499 100644 --- a/plugins/events-node/package.json +++ b/plugins/events-node/package.json @@ -56,6 +56,7 @@ "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "^4.17.6", + "content-type": "^1.0.5", "cross-fetch": "^4.0.0", "express": "^4.17.1", "uri-template": "^2.0.0" diff --git a/plugins/events-node/report.api.md b/plugins/events-node/report.api.md index d68e321e8f..705083ce16 100644 --- a/plugins/events-node/report.api.md +++ b/plugins/events-node/report.api.md @@ -9,6 +9,7 @@ import { AuthService } from '@backstage/backend-plugin-api'; import { DiscoveryService } from '@backstage/backend-plugin-api'; import { LifecycleService } from '@backstage/backend-plugin-api'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { ParsedMediaType } from 'content-type'; import { Request as Request_2 } from 'express'; import { RootConfigService } from '@backstage/backend-plugin-api'; import { ServiceFactory } from '@backstage/backend-plugin-api'; @@ -122,6 +123,7 @@ export type HttpBodyParsed = { // @public (undocumented) export type HttpBodyParser = ( request: Request_2, + parsedMediaType: ParsedMediaType, topic: string, ) => Promise; diff --git a/plugins/events-node/src/api/http/body-parser/HttpBodyParser.ts b/plugins/events-node/src/api/http/body-parser/HttpBodyParser.ts index cfd65a6871..c0b6a46b95 100644 --- a/plugins/events-node/src/api/http/body-parser/HttpBodyParser.ts +++ b/plugins/events-node/src/api/http/body-parser/HttpBodyParser.ts @@ -15,6 +15,7 @@ */ import { Request } from 'express'; +import { ParsedMediaType } from 'content-type'; /** * @public */ @@ -29,5 +30,6 @@ export type HttpBodyParsed = { */ export type HttpBodyParser = ( request: Request, + parsedMediaType: ParsedMediaType, topic: string, ) => Promise; diff --git a/yarn.lock b/yarn.lock index 8d7ec6b3bf..1b4880e8b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6728,6 +6728,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/types": "workspace:^" "@types/express": ^4.17.6 + content-type: ^1.0.5 cross-fetch: ^4.0.0 express: ^4.17.1 msw: ^1.0.0 From cdd27c7e81f31cd8b52ded0d52379ed9ba1bd3a4 Mon Sep 17 00:00:00 2001 From: Rogerio Angeliski Date: Thu, 27 Feb 2025 17:37:02 -0300 Subject: [PATCH 4/4] chore: add missing dep Signed-off-by: Rogerio Angeliski --- plugins/events-node/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/events-node/package.json b/plugins/events-node/package.json index c98ae64499..2a7ff6c01c 100644 --- a/plugins/events-node/package.json +++ b/plugins/events-node/package.json @@ -55,6 +55,7 @@ "@backstage/backend-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", + "@types/content-type": "^1.1.8", "@types/express": "^4.17.6", "content-type": "^1.0.5", "cross-fetch": "^4.0.0", diff --git a/yarn.lock b/yarn.lock index 1b4880e8b1..264bfc572f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6727,6 +6727,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/types": "workspace:^" + "@types/content-type": ^1.1.8 "@types/express": ^4.17.6 content-type: ^1.0.5 cross-fetch: ^4.0.0