From fbf2ffbbd5f729b5e5e9994ea9b6253c19ee3302 Mon Sep 17 00:00:00 2001 From: bludzhd <2344246+bludzhd@users.noreply.github.com> Date: Wed, 28 Aug 2024 20:57:39 +0500 Subject: [PATCH] feat: reply headers --- packages/core/docs/rfc/reply-headers.md | 201 ++++++++++++++++++ packages/core/docs/rfc/service-request.md | 97 +++++++++ .../artifacts/actions/error-headers.ts | 13 ++ .../__tests__/artifacts/actions/headers.ts | 28 +++ .../artifacts/actions/invalid-headers.ts | 14 ++ .../__tests__/suites/router-amqp.spec.ts | 91 +++++++- packages/plugin-router-amqp/src/adapter.ts | 91 +++++--- .../plugin-router-amqp/src/service-request.ts | 45 ++++ .../artifacts/actions/error-headers.ts | 13 ++ .../__tests__/artifacts/actions/headers.ts | 25 +++ .../__tests__/suites/router-hapi.spec.ts | 44 +++- packages/plugin-router-hapi/src/adapter.ts | 66 +++--- packages/plugin-router-hapi/src/attach.ts | 4 +- .../plugin-router-hapi/src/service-request.ts | 61 ++++++ .../__tests__/artifacts/actions/headers.ts | 12 ++ .../__tests__/suites/router-socketio.spec.ts | 30 ++- .../plugin-router-socketio/src/adapter.ts | 32 ++- .../src/service-request.ts | 75 +++++++ .../__tests__/artifacts/actions/headers.ts | 23 ++ .../__tests__/artifacts/schemas/headers.json | 5 + .../__tests__/suites/02.runner.spec.ts | 75 ++++--- .../suites/05.service-request.spec.ts | 50 +++++ packages/plugin-router/schemas/router.json | 10 + packages/plugin-router/src/index.ts | 4 + packages/plugin-router/src/lifecycle/index.ts | 4 + packages/plugin-router/src/plugin.ts | 94 ++++---- packages/plugin-router/src/router.ts | 33 ++- packages/plugin-router/src/service-request.ts | 119 +++++++++++ packages/plugin-router/src/symbols.ts | 1 + packages/plugin-router/src/types/plugin.ts | 3 +- packages/plugin-router/src/types/router.ts | 17 +- 31 files changed, 1198 insertions(+), 182 deletions(-) create mode 100644 packages/core/docs/rfc/reply-headers.md create mode 100644 packages/core/docs/rfc/service-request.md create mode 100644 packages/plugin-router-amqp/__tests__/artifacts/actions/error-headers.ts create mode 100644 packages/plugin-router-amqp/__tests__/artifacts/actions/headers.ts create mode 100644 packages/plugin-router-amqp/__tests__/artifacts/actions/invalid-headers.ts create mode 100644 packages/plugin-router-amqp/src/service-request.ts create mode 100644 packages/plugin-router-hapi/__tests__/artifacts/actions/error-headers.ts create mode 100644 packages/plugin-router-hapi/__tests__/artifacts/actions/headers.ts create mode 100644 packages/plugin-router-hapi/src/service-request.ts create mode 100644 packages/plugin-router-socketio/__tests__/artifacts/actions/headers.ts create mode 100644 packages/plugin-router-socketio/src/service-request.ts create mode 100644 packages/plugin-router/__tests__/artifacts/actions/headers.ts create mode 100644 packages/plugin-router/__tests__/artifacts/schemas/headers.json create mode 100644 packages/plugin-router/__tests__/suites/05.service-request.spec.ts create mode 100644 packages/plugin-router/src/service-request.ts create mode 100644 packages/plugin-router/src/symbols.ts diff --git a/packages/core/docs/rfc/reply-headers.md b/packages/core/docs/rfc/reply-headers.md new file mode 100644 index 000000000..33f763e80 --- /dev/null +++ b/packages/core/docs/rfc/reply-headers.md @@ -0,0 +1,201 @@ +# Reply Headers + + + +## Overview and Motivation +Most transport protocols natively have headers in messages. While currently every Transport Router Adapter is able to +parse incoming messages including its optional headers, in order to fully support messaging protocols the Service SHOULD +provide a way to set, modify and remove **response** message headers. + +## Requirements +* User MUST be able to know whether the reply headers API is supported by current Service Request ActionTransport +* When reply headers API is available, User MUST be able to create, read, update and delete reply header by its name and value + +## Recommendations +* When the Action Handler supports multiple ActionTransports, User SHOULD check whether current Service Request ActionTransport has reply headers API support +* User SHOULD be cautious when adding new ActionTransport to an existing ActionHandler in case new ActionTransport has no reply headers API support + +## Characteristics and concerns +* The reply headers API SHOULD comply with such characteristics as performance, testability, scalability, evolvability, reusability and simplicity as it is the OSS framework core domain characteristics +* The request-response API for each transport MUST be backward compatible +* Reply headers MUST NOT collide with any other properties created in userland code +* Reply headers MUST be correctly passed for both successful and error responses + +## Support +### `ActionTransport.http` +HTTP naturally supports headers. + +`ActionTransport.http` MUST support Reply Headers API. + +### `ActionTransport.amqp` +AMQP allows extending message properties with any properties, AMQP-Transport implements message extension API which allows setting reply headers. +`ActionTransport.amqp` has **request** headers support, so it seems right to pair it with **reply** headers. + +`ActionTransport.amqp` MUST support Reply Headers API. + +### `ActionTransport.socketio` +Technically SocketIO allows listening to Engine.IO events and [modifying response *HTTP* headers](https://socket.io/blog/socket-io-4-1-0/#add-a-way-to-customize-the-response-headers). +However, SocketIO ServiceRequest request and response context is limited to particular event frame. Frame headers are not intended to pass any kind of application data. + +`ActionTransport.socketio` MUST NOT support Reply Headers API. + +See [alternatives](#actiontransportsocketio-1) that have been considered. + +`ActionTransport.socketio` MUST NOT support Reply Headers API. + +### `ActionTransport.internal` +The idea of the Microfleet Internal Transport is to reduce network usage by calling action internally. +In practice, it goes along with `ActionTransport.amqp` and sometimes `ActionTransport.http` Transport support very often. + +Internal Transport response does not have any predefined structure and technical limitations. + +Internal transport has no concept of **reply** headers and no other architectural need of having these than supporting the reply headers API: payload/meta structure could have been implemented inside the returning value. +Internal transport has no concept of **request** headers either. + +The trade-off is about performance, usability, and simplicity: + +Implemented support pros: +* provides simplicity in terms of action handler usability: for the actions that work with `ActionTransport.amqp` and/or `ActionTransport.http`, `ActionTransport.internal` support could be enabled effortlessly + +Implemented support cons: +* increases coupling: in order to know whether to return headers or not - the caller context SHOULD be aware of dispatch internals +* increases complexity: to comply the backward compatibility we have to introduce new concept of dispatch options with some default behavior that tells dispatcher to respond with data only; provide Router configuration options to preset defaults for every dispatch for better developer experience +* increases complexity: introduces concept of headers that are not really headers by [definition](https://en.wikipedia.org/wiki/Header_(computing)) - just another property of a response object, which is semantically incorrect +* decreases performance: each request that goes through router has to resolve dispatch options and returning value format + +No support pros: +* nothing changes + +No support cons: +* decreases simplicity: in order to support multiple types of transport - both `ActionTransport.internal` and any other that have reply headers support - user land code will have to change current action handler and probably provide + +## Headers +Setting headers + * Generally, header value SHOULD be a string + * http: exception, HTTP supports any number of set-cookie response headers, for this case User MUST be able to set array value + * For any other + * Generally, setting the same header value twice will override the value. + * http: exception, HTTP supports any number of set +Array value makes sense and is allowed only as an exception for the HTTP `set-cookie` header. +When you set the value more than once, it gets overriden, except for the `set-cookie` header. + * http: set-cookie is appended, not merged + +## Headers validation +Early validation may help to avoid errors on transport level. It could make sure to provide acceptable string by the Transport, and nothing specific like every header value semantic validation. +The tradeoff here is about whether perform validation at all or not, and what part of the system SHOULD be responsible for it. + +### Requirements +Validation requirements are: +* Originally, constraints are different for each Transport + * HTTP response headers: US-ASCII + * "Newly defined header fields SHOULD limit their field values to US-ASCII octets", - [RFC7230](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.4) + * Set-Cookie header might have multiple values + * Empty strings are allowed + * AMQP message properties: Unicode, UTF-8 + * Reply headers are passed through message properties, broker passes it as binary data and does not restrict its content + * AMQP 0-9-1 spec does not specify character set for the message and [message properties](https://www.rabbitmq.com/resources/specs/amqp0-9-1.pdf) + * RabbitMQ does not specify character set for [message properties](https://www.rabbitmq.com/docs/publishers#message-properties) + * AMQP coffee lib converts message to bytes with `utf8` encoding before publishing it to the queue + * AMQP coffee lib parses bytes as a `utf8` string when retrieving a message from the queue + * Header value could only be a string + * Empty strings are allowed +* Websocket frames do not use headers for application data +* Internal transport do not have headers +* If any transport gets added the validation will probably be quite simple + +### Validation responsibility +What part of system has to be responsible for reply headers validation? +* Service Request class validates itself +* Router Adapter / Router validates Service Request before send +* Userland code + +For now, reply headers validation does not require any external context. Requirements are pretty common and breaking them does not break the code. +As long as it does not, the Service Request MUST be responsible for the data to be logically consistent for the implementation, i.e. data type, but no more than that. +In the future, if we need to make use of external context like AJV validation schemas for some new Transport Service Request, reply headers must be validated on the router adapter level. + +### Validation rules +Validation could be required in two ways: +* common validation rules: strictest validation rules of all Transports +* own validation rules: each ActionTransport will have its own validation rules + +Common validation rules pros: +* Provides simplicity in terms of adding enabling new Transport support for the action handler: if the value sent is valid for one of Transports, it is valid for any of them, and you could enable it at any point + +Common validation rules cons: +* Violates evolvability and backward compatibility: impossible to add modify validation rules, if any new Transport is added +* Makes ServiceRequest too thick does not really improve developer experience, the main reason why we validate headers at all +* The strictest validation rules make values that are perfectly valid on the underlying transport level invalid + +Own validation rules pros: +* Thin validation, rules always comply with Transport specs +* Less coupling between plugins + +Own validation rules cons: +Can not think of any. + +## Setting error reply headers +User SHOULD be able to pass **error** reply headers. Microfleet Service Request SHOULD provide symmetrical functionality as in a happy request-response path. +Headers that were set before error was thrown MUST NOT be passed further. + +Service Request `.error` property is responsible for providing necessary error context. Currently, it passes only data. +But this object could also be used to pass error headers. Each Transport Router Adapter MUST pass error headers responsibly. + +## Service Request +Service Request has to be extended with following properties and methods. + +### Properties +#### New property `.[kReplyHeaders]: Map` +Reply message headers container. Could be set anywhere during the Request Lifecycle. SHOULD be used to collect and +deliver headers to original reply message. + +#### Modify property `.error` type +Request error. Error MUST be able to store reply headers. + +### Methods +#### New method `.hasReplyHeadersSupport(): boolean` +MUST return `true` if the ServiceRequest `ActionTransport` supports reply headers and all reply headers API + +#### New method `.setReplyHeader(title: string, value: number|string|array): ServiceRequest` +Sets reply header to the map. + +MUST normalize title. +MUST cast numeric value to string. +MUST validate title and value. MUST throw exception if any of arguments is invalid. + +MUST throw an Error if the ServiceRequest `ActionTransport` does not support reply headers and all reply headers API + +#### New method `.getReplyHeader(title: string): string|string[]` +MUST normalize title. +MUST return header value from headers container. + +MUST throw an Error if the ServiceRequest `ActionTransport` does not support reply headers and all reply headers API + +#### New method `.removeReplyHeader(title: string): ServiceRequest` +MUST normalize title and remove header from headers container. + +MUST throw an Error if the ServiceRequest `ActionTransport` does not support reply headers and all reply headers API + +#### New method `.getReplyHeaders(): Map` +MUST return all headers from headers container. + +MUST throw an Error if the ServiceRequest `ActionTransport` does not support reply headers and all reply headers API + +#### New method `.clearReplyHeaders(): ServiceRequest` +MUST clear all headers in the headers container. + +MUST throw an Error if the ServiceRequest `ActionTransport` does not support reply headers and all reply headers API + +### New method `.isValidReplyHeader(title: string, value: string|string[]): boolean` +MUST validate title and value. + +MUST throw an Error if the ServiceRequest `ActionTransport` does not support reply headers and all reply headers API + +## Alternatives +Support and validation trade-offs are mostly about what is questionably good once in rare cases of userland code VS what is strategically good for the framework. + +### Support +#### `ActionTransport.socketio` +There is a way to pass the headers as a part of structured payload and enable this optional response structure passing dispatch options, which default to `{ "simpleResponse": true }`. + +### Validation +We could comply with the strictest among all transports validation rules, so that any Action Handler could start to support each of the Transports effortlessly. The team considered this as an option at first. diff --git a/packages/core/docs/rfc/service-request.md b/packages/core/docs/rfc/service-request.md new file mode 100644 index 000000000..62d88ca38 --- /dev/null +++ b/packages/core/docs/rfc/service-request.md @@ -0,0 +1,97 @@ +# Service Request + +## Overview and Motivation +The Service handles incoming messages via several action Transports. On each incoming message Transport Router Adapter +builds an object with `ServiceRequest` type and passes it to the Dispatcher. There is no common constructor function now. +This object becomes available in the Action Handler as an argument. Its structure is abstract and functionality is +basically transport-agnostic. + +Considering Node V8 hidden classes concept, to minimize hidden class trees number and respectfully maximize the +performance, the Service Request instance must always aim to have same object shape and its properties initialization +order. In order to make it even more performant we have to choose functions over ES6 classes for the Service Request +implementation. Due to that we need to use **single constructor function** to instantiate Service Request objects +anywhere. + +## Service Request Interface + +### Properties + +#### `.transport: TransportTypes` +Transport name that handles the incoming request. + +#### `.method: RequestMethods` +Request method. + +Virtual value, which, depending on transport design, should either preserve its original request method name or provide +its custom name. + +#### `.query: object` +Original message may contain query params. + +Transport Router Adapter should extract query data and set it as query. + +Notice that `.query` value may be possibly modified during the Request step of the Validation Lifecycle: it could be +filtered, assigned by default values and underlying data types could be coerced. + +#### `.headers: any` +Original message may contain request headers. Transport Router Adapter should extract headers data and set it as params. +The responsibility for extracting request headers and setting it to the Service Request must lay on the +Transport Router Adapter implementation. + +#### `.params: any` +Original message may contain params. + +Transport Router Adapter should extract request data and set it as params. + +Notice that `.params` value may be possibly modified during the Request step of the Validation Lifecycle: it could be +filtered, assigned by default values and underlying data types could be coerced. + +#### `.transportRequest?: any` +Third-party request instance. + +#### `.log?: { trace(...args: any[]): void; debug(...args: any[]): void; info(...args: any[]): void; warn(...args: any[]): void; error(...args: any[]): void; fatal(...args: any[]): void; }` +Router must set Log child instance with a unique Request identifier. + +#### `.socket?: NodeJS.EventEmitter` +In order to provide web sockets protocol support we need to operate on socket instance. It should be set whenever +socket instance is available. + +#### `.parentSpan` +When a Tracer is enabled, property may hold a tracer parent span, which context must be supplied by the Transport. + +#### `.route: string` +Route name may contain two parts joined by dot - optional Router prefix and required path to the Action Handler, +transformed to dot case. It shall result into the following format: +``` +'router-prefix.path.to.the.action.handler' +``` +Assuming that the Router plugin prefix configured as `'payments'`, the path to the action +relative to the routes directory defined by Router plugin configuration is `'transactions/create'`, resulting route +value will be `payments.transactions.create`. + +*Notice: Route name should be transport-agnostic and therefore must not contain Transport route prefix.* + +Route name must be set during the Request step of the Request Lifecycle. + +#### `.action: ServiceAction` +When the route match is found, the Router must provide an Action instance to the Service Request. + +Action must be set during the Request step of the Request Lifecycle. + +#### `.auth: any` +Original message may contain authentication data. Considering the Action authentication strategy it may be resolved +during the Auth step of Request Lifecycle and set as the `.auth` property value. + +#### `.span` +When a Tracer is enabled, property must hold a tracer span, initialized as a `.parentSpan` child. + +#### `.locals` +By design, this property recommended usage is to share data between Request Lifecycle steps, as well as pass it through +when using Internal Transport. Could be set anywhere during the Request Lifecycle. + +#### `.reformatError` +Flag that defines whether to transform the error or keep to the original one. + +#### `.error` +Request error. + diff --git a/packages/plugin-router-amqp/__tests__/artifacts/actions/error-headers.ts b/packages/plugin-router-amqp/__tests__/artifacts/actions/error-headers.ts new file mode 100644 index 000000000..79d5a5ca6 --- /dev/null +++ b/packages/plugin-router-amqp/__tests__/artifacts/actions/error-headers.ts @@ -0,0 +1,13 @@ +import type { ServiceRequest } from '@microfleet/plugin-router' +import { ActionTransport, kReplyHeaders } from '@microfleet/plugin-router' + +const unsuccessfulAttemptError = new Error('The Unexpected Error... has been thrown... and crashed... everything!!!') +unsuccessfulAttemptError[kReplyHeaders] = new Map([['x-unsuccessful-attempts', '1/10']]) + +export default async function errorHeadersAction(request: ServiceRequest): Promise { + request.setReplyHeader('x-happy-path', 'Things were ob-la-di ob-la-da until...') + + throw unsuccessfulAttemptError +} +errorHeadersAction.schema = false +errorHeadersAction.transports = [ActionTransport.amqp] diff --git a/packages/plugin-router-amqp/__tests__/artifacts/actions/headers.ts b/packages/plugin-router-amqp/__tests__/artifacts/actions/headers.ts new file mode 100644 index 000000000..b7eee6040 --- /dev/null +++ b/packages/plugin-router-amqp/__tests__/artifacts/actions/headers.ts @@ -0,0 +1,28 @@ +import { ok } from 'assert' + +import { ActionTransport } from '@microfleet/plugin-router' +import type { ServiceRequest } from '@microfleet/plugin-router' + +export default async function headersAction(request: ServiceRequest): Promise { + request.setReplyHeader('x-add', 'added') + + request.setReplyHeader('x-add-remove', 'added removed') + ok(request.hasReplyHeader('x-add-remove')) + request.removeReplyHeader('x-add-remove') + + request.setReplyHeader('x-override', 'old') + request.setReplyHeader('X-OVERRIDE', 'new') + + request.setReplyHeader('set-cookie', 'foo=1') + request.setReplyHeader('set-cookie', 'bar=2') + + request.setReplyHeader('x-non-ascii', '👾') + request.setReplyHeader('x-empty', '') + + return { + response: 'success', + } +} + +headersAction.schema = false +headersAction.transports = [ActionTransport.amqp] diff --git a/packages/plugin-router-amqp/__tests__/artifacts/actions/invalid-headers.ts b/packages/plugin-router-amqp/__tests__/artifacts/actions/invalid-headers.ts new file mode 100644 index 000000000..9cfb221cb --- /dev/null +++ b/packages/plugin-router-amqp/__tests__/artifacts/actions/invalid-headers.ts @@ -0,0 +1,14 @@ +import { ActionTransport } from '@microfleet/plugin-router' +import type { ServiceRequest } from '@microfleet/plugin-router' + +export default async function invalidHeadersAction(request: ServiceRequest): Promise { + request.setReplyHeader('x-valid', 'should not be present') + request.setReplyHeader(request.params.key, request.params.value) + + return { + response: 'success', + } +} + +invalidHeadersAction.schema = false +invalidHeadersAction.transports = [ActionTransport.amqp] diff --git a/packages/plugin-router-amqp/__tests__/suites/router-amqp.spec.ts b/packages/plugin-router-amqp/__tests__/suites/router-amqp.spec.ts index 98248413a..7dd46214b 100644 --- a/packages/plugin-router-amqp/__tests__/suites/router-amqp.spec.ts +++ b/packages/plugin-router-amqp/__tests__/suites/router-amqp.spec.ts @@ -1,11 +1,11 @@ -import { strict as assert, deepStrictEqual, rejects } from 'assert' +import { strict as assert, deepStrictEqual, rejects, strictEqual } from 'assert' import { resolve } from 'path' import { ConnectionError } from 'common-errors' import { Microfleet } from '@microfleet/core' import { Lifecycle, ServiceRequest } from '@microfleet/plugin-router' import { spy } from 'sinon' import { RequestCountTracker } from '@microfleet/plugin-router' -import { AMQPTransport, connect } from '@microfleet/transport-amqp' +import { AMQPTransport, connect, kReplyHeaders as kAmqpReplyHeaders } from '@microfleet/transport-amqp' jest.setTimeout(5000) @@ -35,7 +35,7 @@ beforeAll(async () => { level: 'trace', }, }) -}) +}, 10000) afterAll(() => publisher.close()) @@ -442,3 +442,88 @@ describe('AMQP suite: retry + amqp router prefix + router prefix', function test ) }) }) + +describe('AMQP suite: service request', function testSuite() { + const service = new Microfleet({ + name: 'tester', + plugins: [ + 'logger', + 'validator', + 'amqp', + 'router', + 'router-amqp' + ], + amqp: { + transport: { + debug: true, + logOptions: { + level: 'trace' + } + } + }, + logger: { + defaultLogger: { + level: 'trace' + }, + }, + router: { + routes: { + directory: resolve(__dirname, '../artifacts/actions'), + }, + }, + }) + + beforeAll(() => service.connect()) + afterAll(() => service.close()) + + it('should be able to crud headers', async () => { + const { amqp } = service + const response = await amqp.publishAndWait('headers', null, { simpleResponse: false }) + const { headers, data } = response + strictEqual(data.response, 'success') + // amqp-transport header is also present + strictEqual(headers['timeout'], 10000) + // custom headers + strictEqual(headers['x-add'], 'added') + strictEqual(headers['x-override'], 'new') + strictEqual(headers['x-add-remove'], undefined) + // http exception not applied when set through amqp + strictEqual(headers['set-cookie'], 'bar=2') + // non ascii symbol is fine as far as it's unicode + strictEqual(headers['x-non-ascii'], '👾') + // empty header + strictEqual(headers['x-empty'], '') + }) + + it('should be able to validate headers and clear happy path headers', async () => { + const { amqp } = service + await rejects( + amqp.publishAndWait( + 'invalid-headers', + { key: 'x-invalid', value: ['should', 'be', 'a', 'string', 'array', 'given'] }, + { simpleResponse: false } + ), + (error) => { + const headers = error[kAmqpReplyHeaders] + strictEqual(headers['x-invalid'], undefined) + strictEqual(headers['x-valid'], undefined) + strictEqual(headers['timeout'], 10000) + return true + } + ) + }) + + it('should be able to pass error headers and clear happy path headers', async () => { + const { amqp } = service + await rejects( + amqp.publishAndWait('error-headers', null, { simpleResponse: false }), + (error) => { + const headers = error[kAmqpReplyHeaders] + strictEqual(headers['x-happy-path'], undefined) + strictEqual(headers['x-unsuccessful-attempts'], '1/10') + strictEqual(headers['timeout'], 10000) + return true + } + ) + }) +}) diff --git a/packages/plugin-router-amqp/src/adapter.ts b/packages/plugin-router-amqp/src/adapter.ts index af82d4869..07860a72e 100644 --- a/packages/plugin-router-amqp/src/adapter.ts +++ b/packages/plugin-router-amqp/src/adapter.ts @@ -1,9 +1,36 @@ -import { noop, identity } from 'lodash' +import { identity } from 'lodash' import { Microfleet } from '@microfleet/core' -import { ActionTransport, ServiceRequest } from '@microfleet/plugin-router' import { MessageConsumer } from '@microfleet/transport-amqp' import { RouterAMQPPluginConfig } from './types/plugin' -import { Message } from '@microfleet/amqp-coffee' +import { AmqpServiceRequest } from './service-request' +import { kReplyHeaders as kRouterReplyHeaders } from '@microfleet/plugin-router' +import { kReplyHeaders as kAmqpReplyHeaders } from '@microfleet/transport-amqp' + +import type { Message } from '@microfleet/amqp-coffee' + +export function createAmqpRequest( + messageBody: any, + raw: Message, + normalizeActionName: (routingKey: string) => string +) { + const { properties } = raw + const { headers = Object.create(null) } = properties + const routingKey = headers['routing-key'] || raw.routingKey + + // normalize headers access + if (!properties.headers) { + properties.headers = headers + } + + const route = normalizeActionName(routingKey) + + return new (AmqpServiceRequest as any)( + messageBody, + route, + properties, + raw + ) +} function getAMQPRouterAdapter( service: Microfleet, @@ -28,6 +55,7 @@ function getAMQPRouterAdapter( const prefix = config.prefix || '' const prefixLength = prefix ? prefix.length + 1 : 0 + // @todo is it possible to route without prefix trim? const normalizeActionName = prefixLength > 0 ? (routingKey: string): string => ( routingKey.startsWith(prefix) @@ -37,39 +65,32 @@ function getAMQPRouterAdapter( : (routingKey: string): string => routingKey return async (messageBody: any, raw: Message): Promise => { - const { properties } = raw - const { headers = Object.create(null) } = properties - const routingKey = headers['routing-key'] || raw.routingKey - - // normalize headers access - if (!properties.headers) { - properties.headers = headers - } - - // @todo is it possible to route without prefix trim? - const route = normalizeActionName(routingKey) + const serviceRequest = createAmqpRequest(messageBody, raw, normalizeActionName) - const opts: ServiceRequest = { - // initiate action to ensure that we have prepared proto fo the object - // input params - // make sure we standardize the request - // to provide similar interfaces - params: messageBody, - route, - action: noop as any, - headers: properties, - locals: Object.create(null), - log: console as any, - method: ActionTransport.amqp, - parentSpan: null, - query: Object.create(null), - span: null, - transport: ActionTransport.amqp, - transportRequest: raw, - reformatError: true, - } - - return wrapDispatch(service.router.dispatch(opts), route, raw) + return wrapDispatch( + service.router + .dispatch(serviceRequest) + .finally( + () => { + let replyHeaders + const { error } = serviceRequest + if (error) { + if (error[kRouterReplyHeaders]) { + replyHeaders = error[kRouterReplyHeaders] + } else if (error.inner_error && error.inner_error[kRouterReplyHeaders]) { + replyHeaders = error.inner_error[kRouterReplyHeaders] + } else { + replyHeaders = new Map() + } + } else { + replyHeaders = serviceRequest.getReplyHeaders() + } + serviceRequest.transportRequest.extendMessage(kAmqpReplyHeaders, Object.fromEntries(replyHeaders)) + } + ), + serviceRequest.route, + serviceRequest.transportRequest + ) } } diff --git a/packages/plugin-router-amqp/src/service-request.ts b/packages/plugin-router-amqp/src/service-request.ts new file mode 100644 index 000000000..26e105083 --- /dev/null +++ b/packages/plugin-router-amqp/src/service-request.ts @@ -0,0 +1,45 @@ +import { noop } from 'lodash' +import { ActionTransport, BaseServiceRequest } from '@microfleet/plugin-router' + +import type { ServiceRequest } from '@microfleet/plugin-router' + +/** + * @constructor + */ +export function AmqpServiceRequest( + this: ServiceRequest, + params: any, + route: string, + headers: any, + transportRequest: any +) { + BaseServiceRequest.call( + this, + route, + noop as any, // action + params, + headers, + Object.create(null), // query + ActionTransport.amqp, // method + ActionTransport.amqp, // transport + transportRequest, + Object.create(null), // locals + null, // parentSpan + null, // span + console as any, // log + true // reformatError + ) + // ? do we need to preset auth + this.auth = Object.create(null) +} +AmqpServiceRequest.prototype = Object.create(BaseServiceRequest.prototype) +AmqpServiceRequest.prototype.hasReplyHeadersSupport = function () { + return true +} +AmqpServiceRequest.prototype.validateReplyHeader = function (key: string, value: string | Array): ServiceRequest { + if (Array.isArray(value)) { + throw new Error('AMQP reply header value expected to be a string') + } + + return this +} diff --git a/packages/plugin-router-hapi/__tests__/artifacts/actions/error-headers.ts b/packages/plugin-router-hapi/__tests__/artifacts/actions/error-headers.ts new file mode 100644 index 000000000..8c320b638 --- /dev/null +++ b/packages/plugin-router-hapi/__tests__/artifacts/actions/error-headers.ts @@ -0,0 +1,13 @@ +import type { ServiceRequest } from '@microfleet/plugin-router' +import { ActionTransport, kReplyHeaders } from '@microfleet/plugin-router' + +const unsuccessfulAttemptError = new Error('The Unexpected Error... has been thrown... and crashed... everything!!!') +unsuccessfulAttemptError[kReplyHeaders] = new Map([['x-unsuccessful-attempts', '1/10']]) + +export default async function errorHeadersAction(request: ServiceRequest): Promise { + request.setReplyHeader('x-happy-path', 'Things were ob-la-di ob-la-da until...') + + throw unsuccessfulAttemptError +} +errorHeadersAction.schema = false +errorHeadersAction.transports = [ActionTransport.http] diff --git a/packages/plugin-router-hapi/__tests__/artifacts/actions/headers.ts b/packages/plugin-router-hapi/__tests__/artifacts/actions/headers.ts new file mode 100644 index 000000000..695af17d3 --- /dev/null +++ b/packages/plugin-router-hapi/__tests__/artifacts/actions/headers.ts @@ -0,0 +1,25 @@ +import { ok } from 'assert' + +import type { ServiceRequest } from '@microfleet/plugin-router' +import {ActionTransport} from "@microfleet/plugin-router" + +export default async function headersAction(request: ServiceRequest): Promise { + request.setReplyHeader('x-add', 'added') + + request.setReplyHeader('x-add-remove', 'added removed') + ok(request.hasReplyHeader('x-add-remove')) + request.removeReplyHeader('x-add-remove') + + request.setReplyHeader('x-override', 'old') + request.setReplyHeader('x-override', 'new') + + request.setReplyHeader('set-cookie', 'foo=1') + request.setReplyHeader('set-cookie', 'bar=2') + request.setReplyHeader('set-cookie', ['baz=3']) + + return { + response: 'success', + } +} +headersAction.schema = false +headersAction.transports = [ActionTransport.http] diff --git a/packages/plugin-router-hapi/__tests__/suites/router-hapi.spec.ts b/packages/plugin-router-hapi/__tests__/suites/router-hapi.spec.ts index 5e68ca1f6..f66d4a14d 100644 --- a/packages/plugin-router-hapi/__tests__/suites/router-hapi.spec.ts +++ b/packages/plugin-router-hapi/__tests__/suites/router-hapi.spec.ts @@ -1,4 +1,4 @@ -import { strictEqual, deepStrictEqual } from 'assert' +import { strictEqual, deepStrictEqual, ok, rejects } from 'assert' import { resolve } from 'path' import { all } from 'bluebird' import cheerio from 'cheerio' @@ -257,6 +257,48 @@ describe('@microfleet/plugin-router-hapi', () => { }) }) + describe('should be able to use service request api', () => { + const service = new Microfleet({ + name: 'tester', + plugins: ['validator', 'logger', 'router', 'hapi', 'router-hapi'], + hapi: { + server: { + port: 3000, + }, + }, + router: { + routes: { + directory: resolve(__dirname, '../artifacts/actions'), + }, + }, + }) + + beforeAll(() => service.connect()) + afterAll(() => service.close()) + + it('should be able to crud reply headers', async () => { + const response = await fetch('http://0.0.0.0:3000/headers') + const { headers, status } = response + strictEqual(status, 200) + const body = await response.json() + strictEqual(body.response, 'success') + strictEqual(headers.get('x-add'), 'added') + strictEqual(headers.get('x-override'), 'new') + ok(headers.getSetCookie().includes('foo=1')) + ok(headers.getSetCookie().includes('bar=2')) + ok(headers.getSetCookie().includes('baz=3')) + strictEqual(headers.get('x-add-remove'), null) + }) + + it('should be able to pass error headers and clear happy path headers', async () => { + const response = await fetch('http://0.0.0.0:3000/error-headers') + const { headers, status } = response + strictEqual(status, 500) + strictEqual(headers.get('x-happy-path'), null) + strictEqual(headers.get('x-unsuccessful-attempts'), '1/10') + }) + }) + afterAll(async () => { await getGlobalDispatcher().close() }) diff --git a/packages/plugin-router-hapi/src/adapter.ts b/packages/plugin-router-hapi/src/adapter.ts index cc6e6941d..7ee2ef1c3 100644 --- a/packages/plugin-router-hapi/src/adapter.ts +++ b/packages/plugin-router-hapi/src/adapter.ts @@ -1,13 +1,15 @@ import type * as _ from '@microfleet/plugin-opentracing' import Errors from 'common-errors' -import { noop } from 'lodash' -import { FORMAT_HTTP_HEADERS, SpanContext } from 'opentracing' -import { Request } from '@hapi/hapi' +import { FORMAT_HTTP_HEADERS } from 'opentracing' +import { Request, ResponseToolkit } from '@hapi/hapi' import { boomify } from '@hapi/boom' import { Microfleet } from '@microfleet/core' -import { ActionTransport, ServiceRequest } from '@microfleet/plugin-router' import { HttpStatusError } from '@microfleet/validation' +import { HapiServiceRequest } from './service-request' +import { kReplyHeaders } from '@microfleet/plugin-router' +import type { SpanContext } from 'opentracing' +import type { ServiceRequest } from '@microfleet/plugin-router' declare module '@hapi/boom' { interface Payload { @@ -15,12 +17,31 @@ declare module '@hapi/boom' { } } -export default function getHapiAdapter(actionName: string, service: Microfleet): (r: Request) => Promise { +function createServiceRequest(actionName: string, request: Request, parentSpan: SpanContext | null): ServiceRequest { + const { payload, headers, query, method } = request + return new (HapiServiceRequest as any)(actionName, payload, headers, query, method, request, parentSpan) +} + +function resolveResponse(responseToolkit: ResponseToolkit, data: any, headers: any) { + const response = responseToolkit.response(data) + headers.forEach((value: string | Array, key: string) => { + if (Array.isArray(value)) { + const options = key === 'set-cookie' ? { append: true } : {} + value.forEach(item => response.header(key, item, options)) + } else { + response.header(key, value) + } + }) + return response +} + +export default function getHapiAdapter(actionName: string, service: Microfleet): (r: Request, h: ResponseToolkit) => Promise { const { router } = service // pre-wrap the function so that we do not need to actually do fromNode(next) const reformatError = (error: any) => { let statusCode let errorMessage + let headers const { errors } = error @@ -54,16 +75,26 @@ export default function getHapiAdapter(actionName: string, service: Microfleet): } } + // todo less ugly + if (error[kReplyHeaders] instanceof Map) { + headers = Object.fromEntries(error[kReplyHeaders]) + } else if (error.inner_error && error.inner_error[kReplyHeaders] instanceof Map) { + headers = Object.fromEntries(error.inner_error[kReplyHeaders]) + } const replyError = boomify(error, { statusCode, message: errorMessage }) if (error.name) { replyError.output.payload.name = error.name } + if (headers) { + replyError.output.headers = { ...replyError.output.headers, ...headers } + } + return replyError } - return async function handler(request: Request) { + return async function handler(request: Request, responseToolkit: ResponseToolkit) { const { headers } = request let parentSpan: SpanContext | null = null @@ -71,29 +102,12 @@ export default function getHapiAdapter(actionName: string, service: Microfleet): parentSpan = service.tracer.extract(FORMAT_HTTP_HEADERS, headers) } - const serviceRequest: ServiceRequest = { - // defaults for consistent object map - // opentracing - // set to console - // transport type - headers, - parentSpan, - action: noop as any, - locals: Object.create(null), - log: console as any, - method: request.method, - params: request.payload, - query: request.query, - route: actionName, - span: null, - transport: ActionTransport.http, - transportRequest: request, - reformatError: true, - } + const serviceRequest = createServiceRequest(actionName, request, parentSpan) let response try { - response = await router.dispatch(serviceRequest) + const data = await router.dispatch(serviceRequest) + response = resolveResponse(responseToolkit, data, serviceRequest.getReplyHeaders()) } catch (e: any) { response = reformatError(e) } diff --git a/packages/plugin-router-hapi/src/attach.ts b/packages/plugin-router-hapi/src/attach.ts index 5924868ab..06e532a7a 100644 --- a/packages/plugin-router-hapi/src/attach.ts +++ b/packages/plugin-router-hapi/src/attach.ts @@ -58,10 +58,10 @@ export default function attachRouter(service: Microfleet, config: RouterHapiPlug server.route({ method: ['GET', 'POST'], path: '/{any*}', - async handler(request: Request) { + async handler(request: Request, responseToolkit: ResponseToolkit) { const actionName = fromPathToName(request.path, config.prefix) const handler = hapiRouterAdapter(actionName, service) - return handler(request) + return handler(request, responseToolkit) }, }) diff --git a/packages/plugin-router-hapi/src/service-request.ts b/packages/plugin-router-hapi/src/service-request.ts new file mode 100644 index 000000000..04e1ab30a --- /dev/null +++ b/packages/plugin-router-hapi/src/service-request.ts @@ -0,0 +1,61 @@ +import { noop } from 'lodash' +import { ActionTransport, BaseServiceRequest } from '@microfleet/plugin-router' +import { kReplyHeaders } from '@microfleet/plugin-router' +import type { ClientRequest } from 'http' +import type { SpanContext } from 'opentracing' +import type { ServiceRequest } from '@microfleet/plugin-router' +import type { RequestDataKey } from '@microfleet/plugin-router/src/router' + +export function HapiServiceRequest( + this: ServiceRequest, + route: string, + params: any, + headers: any, + query: any, + method: keyof typeof RequestDataKey, + transportRequest: ClientRequest, + parentSpan: SpanContext | null, +) { + BaseServiceRequest.call( + this, + route, + noop as any, + params, + headers, + query, + method, + ActionTransport.http, + transportRequest, + Object.create(null), // locals + parentSpan, + null, // span + console as any, + true // reformatError + ) +} +HapiServiceRequest.prototype = Object.create(BaseServiceRequest.prototype) +HapiServiceRequest.prototype.hasReplyHeadersSupport = function () { + return true +} +HapiServiceRequest.prototype.setReplyHeader = function (key: string, value: string | Array): ServiceRequest { + const lcKey = key.toLowerCase() + let normalizedValue + + if (lcKey === 'set-cookie') { + if (!this[kReplyHeaders].get(lcKey)) { + this[kReplyHeaders].set(lcKey, []) + } + normalizedValue = Array.isArray(value) + ? [...this[kReplyHeaders].get(lcKey), ...value] + : [...this[kReplyHeaders].get(lcKey), value] + } else { + normalizedValue = value + } + BaseServiceRequest.prototype.setReplyHeader.call(this, lcKey, normalizedValue) + + return this +} +HapiServiceRequest.prototype.validateReplyHeader = function () { + return this +} + diff --git a/packages/plugin-router-socketio/__tests__/artifacts/actions/headers.ts b/packages/plugin-router-socketio/__tests__/artifacts/actions/headers.ts new file mode 100644 index 000000000..43a2a3086 --- /dev/null +++ b/packages/plugin-router-socketio/__tests__/artifacts/actions/headers.ts @@ -0,0 +1,12 @@ +import { ActionTransport } from '@microfleet/plugin-router' +import type { ServiceRequest } from '@microfleet/plugin-router' + +export default async function headersAction(request: ServiceRequest): Promise { + request.setReplyHeader('x-error', 'websocket events do not have headers') + + return { + status: 'success', + } +} +headersAction.schema = false +headersAction.transports = [ActionTransport.socketio] diff --git a/packages/plugin-router-socketio/__tests__/suites/router-socketio.spec.ts b/packages/plugin-router-socketio/__tests__/suites/router-socketio.spec.ts index 3b0970931..17967d006 100644 --- a/packages/plugin-router-socketio/__tests__/suites/router-socketio.spec.ts +++ b/packages/plugin-router-socketio/__tests__/suites/router-socketio.spec.ts @@ -1,4 +1,4 @@ -import { strictEqual } from 'assert' +import { strictEqual, rejects } from 'assert' import { Microfleet } from '@microfleet/core' import { resolve } from 'path' import { io as SocketIOClient } from 'socket.io-client' @@ -43,4 +43,32 @@ describe('@microfleet/plugin-router-socketio', () => { }) }) }) + + it('should NOT be able to crud headers', async () => { + const client = SocketIOClient('http://0.0.0.0:17003') + await once(client, 'connect') + try { + await rejects( + new Promise((resolve, reject) => { + client.emit('headers', {}, (error: any, response: any) => { + if (error) { + reject(error) + } else ( + resolve(response) + ) + }) + }), + (error) => { + strictEqual(error.name, 'Error') + strictEqual(error.args[0], 'Something went wrong: Websocket events do not support headers') + strictEqual(error.args[1].output.payload.statusCode, 500) + strictEqual(error.args[1].output.payload.message, 'An internal server error occurred') + + return true + } + ) + } finally { + client.close() + } + }) }) diff --git a/packages/plugin-router-socketio/src/adapter.ts b/packages/plugin-router-socketio/src/adapter.ts index ba0906ba5..c0be865e2 100644 --- a/packages/plugin-router-socketio/src/adapter.ts +++ b/packages/plugin-router-socketio/src/adapter.ts @@ -1,40 +1,32 @@ import { Socket } from 'socket.io' -import { noop } from 'lodash' import { Logger } from '@microfleet/plugin-logger' -import { Router, ActionTransport, ServiceRequest } from '@microfleet/plugin-router' +import { Router, ActionTransport } from '@microfleet/plugin-router' +import { SocketIoServiceRequest } from './service-request' /* Decrease request count on response */ const decreaseRequestCount = (router: Router) => { router.requestCountTracker.decrease(ActionTransport.socketio) } +function createServiceRequest(actionName: string, params: any, callback: any, socket: Socket) { + return new (SocketIoServiceRequest as any)( + actionName, // route + params, + [actionName, params, callback], // transportRequest + socket + ) +} + function getSocketIORouterAdapter(router: Router, log: Logger): (socket: Socket) => void { return function socketIORouterAdapter(socket: Socket): void { socket.onAny(async (actionName: string, params: unknown, callback: CallableFunction): Promise => { - // @todo if (callback !== undefined && typeof callback !== 'function') { if (typeof callback !== 'function') { // ignore malformed rpc call log.warn({ actionName, params, callback }, 'malformed rpc call') return } - const request: ServiceRequest = { - socket, - params, - action: noop as any, - headers: Object.create(null), - locals: Object.create(null), - // @todo real logger - log: console as any, - method: 'socketio', - parentSpan: null, - query: Object.create(null), - route: actionName, - span: null, - transport: ActionTransport.socketio, - transportRequest: [actionName, params, callback], - reformatError: true, - } + const request = createServiceRequest(actionName, params, callback, socket) /* Increase request count on message */ router.requestCountTracker.increase(ActionTransport.socketio) diff --git a/packages/plugin-router-socketio/src/service-request.ts b/packages/plugin-router-socketio/src/service-request.ts new file mode 100644 index 000000000..e05de5b08 --- /dev/null +++ b/packages/plugin-router-socketio/src/service-request.ts @@ -0,0 +1,75 @@ +import { noop } from 'lodash' +import {ActionTransport, BaseServiceRequest, kReplyHeaders} from '@microfleet/plugin-router' + +import type { Socket } from 'socket.io' +import type { ServiceRequest } from '@microfleet/plugin-router' + +const NoReplyHeadersSupportError = new Error('Websocket events do not support headers') + +/** + * @constructor + */ +export function SocketIoServiceRequest( + this: ServiceRequest, + route: string, + params: any, + transportRequest: any, + socket: Socket +) { + BaseServiceRequest.call( + this, + route, + noop as any, // action + params, + Object.create(null), // headers + Object.create(null), // query + ActionTransport.socketio, // transport + ActionTransport.socketio, // method + transportRequest, + Object.create(null), // locals + null, // parentSpan + null, // span + console as any, // log + true // reformatError + ) + this.socket = socket +} +SocketIoServiceRequest.prototype = Object.create(BaseServiceRequest.prototype) + +SocketIoServiceRequest.prototype.hasReplyHeadersSupport = function () { + return false +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +SocketIoServiceRequest.prototype.getReplyHeaders = function () { + throw NoReplyHeadersSupportError +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +SocketIoServiceRequest.prototype.getReplyHeader = function (_key: string): any | undefined { + throw NoReplyHeadersSupportError +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +SocketIoServiceRequest.prototype.hasReplyHeader = function (_key: string): boolean { + throw NoReplyHeadersSupportError +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +SocketIoServiceRequest.prototype.removeReplyHeader = function (_key: string): ServiceRequest { + throw NoReplyHeadersSupportError +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +SocketIoServiceRequest.prototype.setReplyHeader = function (_key: string, _value: string | Array): ServiceRequest { + throw NoReplyHeadersSupportError +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +SocketIoServiceRequest.prototype.validateReplyHeader = function (_key: string, _value: string | Array): ServiceRequest { + throw NoReplyHeadersSupportError +} + +SocketIoServiceRequest.prototype.clearReplyHeaders = function () { + throw NoReplyHeadersSupportError +} diff --git a/packages/plugin-router/__tests__/artifacts/actions/headers.ts b/packages/plugin-router/__tests__/artifacts/actions/headers.ts new file mode 100644 index 000000000..7f25635a6 --- /dev/null +++ b/packages/plugin-router/__tests__/artifacts/actions/headers.ts @@ -0,0 +1,23 @@ +import { ok } from 'assert' +import type { ServiceRequest } from '@microfleet/plugin-router' + +export default async function headersAction(request: ServiceRequest): Promise { + request.setReplyHeader('x-add', 'added') + + request.setReplyHeader('x-add-remove', 'added removed') + ok(request.hasReplyHeader('x-add-remove')) + request.removeReplyHeader('x-add-remove') + + request.setReplyHeader('x-override', 'old') + request.setReplyHeader('X-OVERRIDE', 'new') + + request.setReplyHeader('set-cookie', 'foo=1') + request.setReplyHeader('set-cookie', 'bar=2') + + request.setReplyHeader('x-non-ascii', '👾') + request.setReplyHeader('x-empty', '') + + return { + response: 'success', + } +} diff --git a/packages/plugin-router/__tests__/artifacts/schemas/headers.json b/packages/plugin-router/__tests__/artifacts/schemas/headers.json new file mode 100644 index 000000000..39fb5ec6a --- /dev/null +++ b/packages/plugin-router/__tests__/artifacts/schemas/headers.json @@ -0,0 +1,5 @@ +{ + "$id": "headers", + "type": "object", + "additionalProperties": false +} diff --git a/packages/plugin-router/__tests__/suites/02.runner.spec.ts b/packages/plugin-router/__tests__/suites/02.runner.spec.ts index bd1fff147..85068aac1 100644 --- a/packages/plugin-router/__tests__/suites/02.runner.spec.ts +++ b/packages/plugin-router/__tests__/suites/02.runner.spec.ts @@ -2,9 +2,18 @@ import assert, { rejects, strictEqual } from 'node:assert/strict' import { Microfleet } from '@microfleet/core' import { runHandler, runHook } from '../../src/lifecycle/utils' +import { InternalServiceRequest, ServiceRequest } from '@microfleet/plugin-router' // @todo tests for lifecycle +const dummyRequest = (options?: { response: any }): ServiceRequest => { + const request = (new InternalServiceRequest({}, {}, null, {})) as ServiceRequest + if (options && options.response !== undefined) { + request.response = options.response + } + return request +} + describe('@microfleet/plugin-router: "runner" utils', () => { const context = new Microfleet({ name: 'tester', @@ -13,16 +22,16 @@ describe('@microfleet/plugin-router: "runner" utils', () => { }, }) - it('shoul be able to run', async () => { + it('should be able to run', async () => { const hooks: any = new Map([ ['preHandler', new Set([ - async (req: any) => { req.response += 1 }, - async (req: any) => { req.response += 1 }, + async (req: ServiceRequest) => { req.response += 1 }, + async (req: ServiceRequest) => { req.response += 1 }, ])], ]) - const request = { + const request = dummyRequest({ response: 0, - } as any + }) await runHook(hooks, 'preHandler', context, request) @@ -31,9 +40,9 @@ describe('@microfleet/plugin-router: "runner" utils', () => { it('should not throw if run unknown id', async () => { const hooks: any = new Map([]) - const request = { + const request = dummyRequest({ response: 1, - } as any + }) await runHook(hooks, 'preHandler', context, request) @@ -43,12 +52,12 @@ describe('@microfleet/plugin-router: "runner" utils', () => { it('should be able to run function', async () => { const hooks: any = new Map([ ['preHandler', new Set([ - async (req: any) => { req.response += 1 }, + async (req: ServiceRequest) => { req.response += 1 }, ])], ]) - const request = { + const request = dummyRequest({ response: 0, - } as any + }) const handler = async (req: any) => { req.response += 1 } await runHandler(handler, hooks, 'preHandler', 'postHandler', context, request) @@ -59,13 +68,13 @@ describe('@microfleet/plugin-router: "runner" utils', () => { it('should be able to throw error on pre', async () => { const hooks: any = new Map([ ['preHandler', new Set([ - async (req: any) => { req.error = 'perchik' }, - async (req: any) => { throw new Error(`the name of the fattest cat is ${req.error}`) }, + async (req: ServiceRequest) => { req.error = 'perchik' }, + async (req: ServiceRequest) => { throw new Error(`the name of the fattest cat is ${req.error}`) }, ])], ]) - const request = { + const request = dummyRequest({ response: 0, - } as any + }) const handler = async (req: any) => { req.response += 1 } await rejects( @@ -77,11 +86,11 @@ describe('@microfleet/plugin-router: "runner" utils', () => { it('should return result from handler with "pre-handler"', async () => { const hooks: any = new Map([ ['preHandler', new Set([ - async (req: any) => { req.cat = 'perchik' }, + async (req: ServiceRequest) => { req.cat = 'perchik' }, ])], ]) - const request = {} as any - const handler = async (req: any) => { req.response = req.cat } + const request = dummyRequest() + const handler = async (req: ServiceRequest) => { req.response = req.cat } await runHandler(handler, hooks, 'preHandler', 'postHandler', context, request) @@ -91,8 +100,8 @@ describe('@microfleet/plugin-router: "runner" utils', () => { it('should return result from handler', async () => { const hooks: any = new Map() - const request = {} as any - const handler = async (req: any) => { req.response = 'perchik' } + const request = dummyRequest() + const handler = async (req: ServiceRequest) => { req.response = 'perchik' } await runHandler(handler, hooks, 'preHandler', 'postHandler', context, request) @@ -101,7 +110,7 @@ describe('@microfleet/plugin-router: "runner" utils', () => { it('should be able to throw error from handler', async () => { const hooks: any = new Map() - const request = {} as any + const request = dummyRequest() const handler = async () => { throw new Error('too fat cat') } rejects( @@ -113,14 +122,14 @@ describe('@microfleet/plugin-router: "runner" utils', () => { it('should be able to error from post-handler', async () => { const hooks: any = new Map([ ['postHandler', new Set([ - async (req: any) => { req.error = 'perchik' }, - async (req: any) => { throw new Error(`the name of the fattest cat is ${req.error}`) }, + async (req: ServiceRequest) => { req.error = 'perchik' }, + async (req: ServiceRequest) => { throw new Error(`the name of the fattest cat is ${req.error}`) }, ])], ]) - const request = { + const request = dummyRequest({ response: 0, - } as any - const handler = async (req: any) => { req.response += 1 } + }) + const handler = async (req: ServiceRequest) => { req.response += 1 } await rejects( runHandler(handler, hooks, 'preHandler', 'postHandler', context, request), @@ -131,11 +140,11 @@ describe('@microfleet/plugin-router: "runner" utils', () => { it('should be able to modify result if no error returned from handler', async () => { const hooks: any = new Map([ ['postHandler', new Set([ - async (req: any) => { req.result = 'perchik' }, + async (req: ServiceRequest) => { req.result = 'perchik' }, ])], ]) - const request = {} as any - const handler = async (req: any) => { req.result = 'persik' } + const request = dummyRequest() + const handler = async (req: ServiceRequest) => { req.result = 'persik' } await runHandler(handler, hooks, 'preHandler', 'postHandler', context, request) @@ -145,10 +154,10 @@ describe('@microfleet/plugin-router: "runner" utils', () => { it('should be able to modify error returned from handler', async () => { const hooks: any = new Map([ ['postHandler', new Set([ - async (req: any) => { throw new Error(`the name of the fattest cat is ${req.error.message}`) }, + async (req: ServiceRequest) => { throw new Error(`the name of the fattest cat is ${req.error.message}`) }, ])], ]) - const request = {} as any + const request = dummyRequest() const handler = async () => { throw new Error('perchik') } await rejects( @@ -160,11 +169,11 @@ describe('@microfleet/plugin-router: "runner" utils', () => { it('should be able to pass arguments to post-handler', async () => { const hooks: any = new Map([ ['postHandler', new Set([ - async (req: any) => { throw new Error(`the name of the fattest cat is ${req.response}`) }, + async (req: ServiceRequest) => { throw new Error(`the name of the fattest cat is ${req.response}`) }, ])], ]) - const request = {} as any - const handler = async (req: any) => { req.response = 'perchik' } + const request = dummyRequest() + const handler = async (req: ServiceRequest) => { req.response = 'perchik' } await rejects( () => runHandler(handler, hooks, 'preHandler', 'postHandler', context, request), diff --git a/packages/plugin-router/__tests__/suites/05.service-request.spec.ts b/packages/plugin-router/__tests__/suites/05.service-request.spec.ts new file mode 100644 index 000000000..9a2212573 --- /dev/null +++ b/packages/plugin-router/__tests__/suites/05.service-request.spec.ts @@ -0,0 +1,50 @@ +import { strictEqual, ok } from 'node:assert' +import { resolve } from 'path' +import { Microfleet } from '@microfleet/core' + +describe('Service Request', () => { + let service: Microfleet + + afterAll(async () => { + await service?.close() + }) + + it('should be able to manage reply headers', async () => { + service = new Microfleet({ + name: 'tester', + plugins: [ + 'validator', + 'logger', + 'router' + ], + // @todo one style for pass directory? + validator: { schemas: ['../artifacts/schemas'] }, + logger: { + defaultLogger: false, + }, + router: { + routes: { + // @todo one style for pass directory? + directory: resolve(__dirname, '../artifacts/actions'), + prefix: 'action', + }, + }, + }) + + await service.connect() + + const simpleResponse = await service.dispatch('headers', { params: {} }, { simpleResponse: true }) + ok(simpleResponse.response, 'success') + + const notSimpleResponse = await service.dispatch('headers', { params: {} }, { simpleResponse: false }) + const { headers, data } = notSimpleResponse + strictEqual(data.response, 'success') + strictEqual(headers.get('x-add'), 'added') + strictEqual(headers.get('x-override'), 'new') + strictEqual(headers.get('x-add-remove'), undefined) + // http exception not applied when set through internal transport + strictEqual(headers.get('set-cookie'), 'bar=2') + strictEqual(headers.get('x-non-ascii'), '👾') + strictEqual(headers.get('x-empty'), '') + }) +}) diff --git a/packages/plugin-router/schemas/router.json b/packages/plugin-router/schemas/router.json index 7014b6be6..f2a8f0990 100644 --- a/packages/plugin-router/schemas/router.json +++ b/packages/plugin-router/schemas/router.json @@ -121,6 +121,16 @@ } } } + }, + "dispatchOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "simpleResponse": { + "type": "boolean", + "default": true + } + } } } } diff --git a/packages/plugin-router/src/index.ts b/packages/plugin-router/src/index.ts index 3e6b63caa..6891bc65e 100644 --- a/packages/plugin-router/src/index.ts +++ b/packages/plugin-router/src/index.ts @@ -19,6 +19,10 @@ export type { ServiceActionHandler, ServiceActionAuthGetName, TransportOptions, + DispatchOptions, } from './types/router' +export { BaseServiceRequest, InternalServiceRequest } from './service-request' export type { AuthInfo } from './lifecycle/handlers/auth' + +export * from './symbols' diff --git a/packages/plugin-router/src/lifecycle/index.ts b/packages/plugin-router/src/lifecycle/index.ts index 00e514c36..7c666b283 100644 --- a/packages/plugin-router/src/lifecycle/index.ts +++ b/packages/plugin-router/src/lifecycle/index.ts @@ -131,6 +131,10 @@ export class Lifecycle { await runHandler(this.validateResponseHandler, hooks, preValidateResponse, postValidateResponse, context, request) } catch (error: any) { request.error = error + // todo is it a right place to clear reply headers? add some error handler? + if (request.hasReplyHeadersSupport()) { + request.clearReplyHeaders() + } } await runHandler(responseHandler, hooks, preResponse, postResponse, context, request) diff --git a/packages/plugin-router/src/plugin.ts b/packages/plugin-router/src/plugin.ts index 0f05db3d4..b76eed7cc 100644 --- a/packages/plugin-router/src/plugin.ts +++ b/packages/plugin-router/src/plugin.ts @@ -5,14 +5,15 @@ import { isObject } from 'lodash' import { Microfleet, PluginTypes } from '@microfleet/core' import { defaultsDeep } from '@microfleet/utils' -import { Router, ActionTransport } from './router' +import { Router, defaultDispatchOptions } from './router' import Routes from './routes' import Tracker from './tracker' import { auditLog } from './extensions/index' import { Lifecycle } from './lifecycle/index' +import { InternalServiceRequest } from './service-request' import type { RouterPluginConfig } from './types/plugin' -import type { ServiceRequest } from './types/router' +import type { ServiceRequest, DispatchOptions } from './types/router' import type { PluginInterface } from '@microfleet/core-types' export const name = 'router' @@ -31,54 +32,39 @@ const shallowObjectClone = (prop: any) => isObject(prop) */ const deepClone = rfdc() -/** - * Fills gaps in default service request. - * @param request - service request. - * @returns Prepared service request. - */ -const prepareInternalRequest = (request: Partial): ServiceRequest => ({ - // initiate action to ensure that we have prepared proto fo the object - // input params - // make sure we standardize the request - // to provide similar interfaces - action: null as any, - headers: shallowObjectClone(request.headers), - locals: shallowObjectClone(request.locals), - auth: shallowObjectClone(request.auth), - log: console as any, - method: ActionTransport.internal, - params: request.params != null - ? deepClone(request.params) - : Object.create(null), - parentSpan: null, - span: null, - query: Object.create(null), - route: '', - transport: ActionTransport.internal, - transportRequest: Object.create(null), - reformatError: false, -}) +export function createInternalRequest(request: Partial): ServiceRequest { + const { params, headers, locals } = request + return new (InternalServiceRequest as any)( + params != null + ? deepClone(params) + : Object.create(null), + shallowObjectClone(headers), + request, + shallowObjectClone(locals), + ) +} const defaultConfig: Partial = { - /* Routes configuration */ - routes: { - /* Directory to scan for actions. */ - directory: resolve(process.cwd(), 'src/actions'), - /* Enables health action by default */ - enabledGenericActions: [ - 'health', - ], - /* Enables response validation. */ - responseValidation: { - enabled: false, - maxSample: 7, - panic: false, - } - }, - /* Extensions configuration */ - extensions: { - register: [auditLog()], - }, + /* Routes configuration */ + routes: { + /* Directory to scan for actions. */ + directory: resolve(process.cwd(), 'src/actions'), + /* Enables health action by default */ + enabledGenericActions: [ + 'health', + ], + /* Enables response validation. */ + responseValidation: { + enabled: false, + maxSample: 7, + panic: false, + } + }, + /* Extensions configuration */ + extensions: { + register: [auditLog()], + }, + dispatchOptions: defaultDispatchOptions, } export async function attach( @@ -100,8 +86,9 @@ export async function attach( enabled, allRoutes, enabledGenericActions, - responseValidation: validateResponse - } + responseValidation: validateResponse, + }, + dispatchOptions } = this.validator.ifError('router', defaultsDeep(options, defaultConfig)) const routes = new Routes() @@ -119,14 +106,15 @@ export async function attach( enabled, enabledGenericActions, allRoutes, + dispatchOptions, }, log: this.log, - requestCountTracker: new Tracker(this) + requestCountTracker: new Tracker(this), }) // dispatcher - this.dispatch = (route: string, request: Partial) => - router.prefixAndDispatch(route, prepareInternalRequest(request)) + this.dispatch = (route: string, request: Partial, dispatchOptions?: Partial) => + router.prefixAndDispatch(route, createInternalRequest(request), dispatchOptions) return { async connect() { diff --git a/packages/plugin-router/src/router.ts b/packages/plugin-router/src/router.ts index 9b592eef8..91ed6b03a 100644 --- a/packages/plugin-router/src/router.ts +++ b/packages/plugin-router/src/router.ts @@ -4,17 +4,19 @@ import hyperid from 'hyperid' import { Tracer } from 'opentracing' import { Logger } from '@microfleet/plugin-logger' import { glob } from 'glob' +import { defaults } from 'lodash' import RequestCountTracker from './tracker' import Routes from './routes' import { Lifecycle } from './lifecycle' -import { ServiceAction, ServiceRequest } from './types/router' +import { ServiceAction } from './types/router' import { RouterPluginRoutesConfig } from './types/plugin' import { readRoutes, createServiceAction, requireServiceActionHandler, } from './utils' +import type { ServiceRequest, DispatchOptions } from './types/router' const { COMPONENT, ERROR } = Tags @@ -27,7 +29,7 @@ export type RouterOptions = { tracer?: Tracer } -export type RouterConfig = RouterPluginRoutesConfig +export type RouterConfig = RouterPluginRoutesConfig & { dispatchOptions: DispatchOptions } const finishSpan = ({ span }: ServiceRequest) => () => { if (span != null) { @@ -72,6 +74,8 @@ export const RequestDataKey = { socketio: 'params', } as const +export const defaultDispatchOptions = { simpleResponse: true } + export class Router { public readonly config?: RouterConfig public readonly routes: Routes @@ -84,6 +88,7 @@ export class Router { protected readonly idgen: hyperid.Instance protected readonly directory?: string protected readonly enabledGenericActions?: string[] + protected readonly dispatchOptions: DispatchOptions constructor({ lifecycle, routes, config, requestCountTracker, log, tracer }: RouterOptions) { this.lifecycle = lifecycle @@ -104,6 +109,7 @@ export class Router { this.directory = directory this.enabledGenericActions = enabledGenericActions } + this.dispatchOptions = defaults(config?.dispatchOptions, defaultDispatchOptions) } public async ready(): Promise { @@ -188,12 +194,27 @@ export class Router { } } - public async prefixAndDispatch(routeWithoutPrefix: string, request: ServiceRequest): Promise { + public async prefixAndDispatch(routeWithoutPrefix: string, request: ServiceRequest, options?: Partial): Promise { request.route = this.prefixRoute(routeWithoutPrefix) - return this.dispatch(request) + return this.dispatch(request, options) + } + + public resolveDispatchOptions(options?: Partial): DispatchOptions { + return defaults(options, this.dispatchOptions) + } + + public resolveResponse(request: ServiceRequest, options: DispatchOptions): any { + const { response: data } = request + if (options.simpleResponse) { + return data + } + + const headers = request.hasReplyHeadersSupport() ? request.getReplyHeaders() : undefined + + return { data, headers } } - public async dispatch(request: ServiceRequest): Promise { + public async dispatch(request: ServiceRequest, options?: Partial): Promise { assert(request.route) assert(request.transport) @@ -228,7 +249,7 @@ export class Router { finishSpan(request) } - return request.response + return this.resolveResponse(request, this.resolveDispatchOptions(options)) } } diff --git a/packages/plugin-router/src/service-request.ts b/packages/plugin-router/src/service-request.ts new file mode 100644 index 000000000..3a06e65f6 --- /dev/null +++ b/packages/plugin-router/src/service-request.ts @@ -0,0 +1,119 @@ +import { ActionTransport } from './router' +import { kReplyHeaders } from './symbols' + +import type { ClientRequest } from 'http' +import type { Span, SpanContext } from 'opentracing' +import type { Logger } from '@microfleet/plugin-logger' +import type { ServiceAction, ServiceRequest } from './types/router' +import type { RequestDataKey } from './router' + +/** + * @constructor + */ +export function BaseServiceRequest( + this: ServiceRequest, + route: string, + action: ServiceAction, + params: any, + headers: any, + query: any, + method: keyof typeof RequestDataKey, + transport: typeof ActionTransport[keyof typeof ActionTransport], + transportRequest: any | ClientRequest, + locals: any, + parentSpan: SpanContext | null, + span: Span | null, + log: Logger, + reformatError: boolean +) { + this.route = route + this.action = action + this.params = params + this.headers = headers + this.query = query + this.method = method + this.transport = transport + this.transportRequest = transportRequest + this.locals = locals + this.parentSpan = parentSpan + this.span = span + this.log = log + this.reformatError = reformatError + this.response = undefined + this.error = undefined + this[kReplyHeaders] = new Map() +} + +BaseServiceRequest.prototype.hasReplyHeadersSupport = function () { + throw new Error('Method must be implemented in inherited prototype') +} + +BaseServiceRequest.prototype.getReplyHeaders = function () { + return this[kReplyHeaders] +} + +BaseServiceRequest.prototype.getReplyHeader = function (key: string): any | undefined { + return this[kReplyHeaders].get(key.toLowerCase()) +} + +BaseServiceRequest.prototype.hasReplyHeader = function (key: string): boolean { + return this[kReplyHeaders].has(key.toLowerCase()) +} +BaseServiceRequest.prototype.removeReplyHeader = function (key: string): ServiceRequest { + delete this[kReplyHeaders].delete(key.toLowerCase()) + + return this +} + +BaseServiceRequest.prototype.setReplyHeader = function (key: string, value: string | Array): ServiceRequest { + this.validateReplyHeader(key, value) + + this[kReplyHeaders].set(key.toLowerCase(), value) + + return this +} +BaseServiceRequest.prototype.clearReplyHeaders = function () { + return this[kReplyHeaders].clear() +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +BaseServiceRequest.prototype.validateReplyHeader = function (_key: string, _value: string | Array): ServiceRequest { + throw new Error('Method must be implemented in inherited prototype') +} + +/** + * @constructor + */ +export function InternalServiceRequest( + this: ServiceRequest, + params: any, + headers: any, + transportRequest: any | ClientRequest, + locals: any +) { + BaseServiceRequest.call( + this, + '', // route + null as any, // action + params, + headers, + Object.create(null), // query + ActionTransport.internal, // method + ActionTransport.internal, // transport + transportRequest, // transportRequest + locals, + null, // parentSpan + null, // span + console as any, // log + false // reformatError + ) + this.auth = Object.create(null) +} +InternalServiceRequest.prototype = Object.create(BaseServiceRequest.prototype) +InternalServiceRequest.prototype.hasReplyHeadersSupport = function () { + return true +} +InternalServiceRequest.prototype.validateReplyHeader = function (_key: string, _value: string | Array): ServiceRequest { + return this +} + diff --git a/packages/plugin-router/src/symbols.ts b/packages/plugin-router/src/symbols.ts new file mode 100644 index 000000000..8f75fd71f --- /dev/null +++ b/packages/plugin-router/src/symbols.ts @@ -0,0 +1 @@ +export const kReplyHeaders = Symbol('microfleet.router.reply.headers') diff --git a/packages/plugin-router/src/types/plugin.ts b/packages/plugin-router/src/types/plugin.ts index 3e265697c..056cdcb4e 100644 --- a/packages/plugin-router/src/types/plugin.ts +++ b/packages/plugin-router/src/types/plugin.ts @@ -1,5 +1,5 @@ import Router from '../router' -import { ServiceAction, ServiceRequest } from './router' +import { DispatchOptions, ServiceAction, ServiceRequest } from './router' import { AuthConfig } from '../lifecycle/handlers/auth' import { ValidateResponseConfig } from '../lifecycle/handlers/validate-response' import { LifecycleExtensions } from '../lifecycle' @@ -21,6 +21,7 @@ export type RouterPluginConfig = { register: LifecycleExtensions[] } routes: RouterPluginRoutesConfig + dispatchOptions: DispatchOptions } export interface RouterPluginRoutesConfig { diff --git a/packages/plugin-router/src/types/router.ts b/packages/plugin-router/src/types/router.ts index 7d2f6b6da..eb98930f3 100644 --- a/packages/plugin-router/src/types/router.ts +++ b/packages/plugin-router/src/types/router.ts @@ -3,6 +3,7 @@ import { Span, SpanContext } from 'opentracing' import { Microfleet } from '@microfleet/core' import { Logger } from '@microfleet/plugin-logger' import { RequestDataKey, ActionTransport } from '../router' +import { kReplyHeaders } from '../symbols' export type ServiceMiddleware = (this: Microfleet, request: ServiceRequest) => Promise export type ServiceActionAuthGetName = (request: ServiceRequest) => string @@ -22,6 +23,7 @@ export interface ServiceAction { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface TransportOptions {} +export type ReplyHeaders = Map> export interface ServiceRequest { route: string action: ServiceAction @@ -36,6 +38,19 @@ export interface ServiceRequest { span: Span | null log: Logger response?: unknown - error?: any + error?: any | Error & { headers?: ReplyHeaders } reformatError: boolean + [kReplyHeaders]: ReplyHeaders; + hasReplyHeadersSupport(): boolean + getReplyHeaders(): Map> + getReplyHeader(title: string): string | Array | undefined + hasReplyHeader(title: string): boolean + setReplyHeader(title: string, value: string | Array): ServiceRequest + removeReplyHeader(title: string): ServiceRequest + isValidReplyHeader(title: string, value: string | Array): boolean + clearReplyHeaders(): ServiceRequest +} + +export interface DispatchOptions { + simpleResponse: boolean }