Add more control on http trigger (#21216)
add "new Response" utils to define response code or content type of http route triggered logic function responses follow up of https://github.com/twentyhq/twenty/pull/21214 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -145,6 +145,33 @@ const handler = async (event: RoutePayload) => {
|
||||
Header names are normalized to lowercase. Access them using lowercase keys (e.g., `event.headers['content-type']`).
|
||||
</Note>
|
||||
|
||||
#### Custom HTTP response
|
||||
|
||||
By default, returning a plain value from your handler sends it back as a `200` response (JSON for objects, `text/plain` for strings). To control the status code and response headers, return a `Response` from `twenty-sdk/logic-function`:
|
||||
|
||||
```ts
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
return new Response('<h1>Hello</h1>', {
|
||||
status: 201,
|
||||
headers: { 'content-type': 'text/html' },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
For security reasons, response headers are restricted to an allow-list. Any header that is not on the list (e.g. `Set-Cookie`, CORS headers such as `Access-Control-Allow-Origin`, or custom `X-*` headers) is silently dropped before the response is sent. The allowed response headers are:
|
||||
|
||||
- `content-type`
|
||||
- `content-language`
|
||||
- `content-disposition`
|
||||
- `cache-control`
|
||||
- `retry-after`
|
||||
|
||||
<Note>
|
||||
The status code must be a valid HTTP status code (between 100 and 599). Response header names are matched case-insensitively.
|
||||
</Note>
|
||||
|
||||
#### Database event trigger payload
|
||||
|
||||
When a database event trigger invokes your logic function, it receives one `DatabaseEventPayload` per changed record. The payload combines metadata about the source workspace and object with the record-level event.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { isLogicFunctionHttpResponse } from 'twenty-shared/types';
|
||||
|
||||
import { Response } from '@/sdk/logic-function/response';
|
||||
|
||||
describe('Response', () => {
|
||||
it('stores body, status and headers', () => {
|
||||
const response = new Response('<h1>Hi</h1>', {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
|
||||
expect(response.body).toBe('<h1>Hi</h1>');
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.headers).toEqual({ 'Content-Type': 'text/html' });
|
||||
});
|
||||
|
||||
it('defaults status and headers to undefined', () => {
|
||||
const response = new Response({ ok: true });
|
||||
|
||||
expect(response.status).toBeUndefined();
|
||||
expect(response.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is detected as an http response after JSON round-trip', () => {
|
||||
const response = new Response({ ok: true }, { status: 200 });
|
||||
|
||||
const roundTripped = JSON.parse(JSON.stringify(response));
|
||||
|
||||
expect(isLogicFunctionHttpResponse(roundTripped)).toBe(true);
|
||||
expect(roundTripped.body).toEqual({ ok: true });
|
||||
expect(roundTripped.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -44,3 +44,5 @@ export type { ListConnectionsFilter } from '@/sdk/logic-function/connections/lis
|
||||
export { findConnectionForRequest } from '@/sdk/logic-function/connections/find-connection-for-request';
|
||||
export { AppConnectionAuthFailedError } from '@/sdk/logic-function/connections/errors/app-connection-auth-failed.error';
|
||||
export type { AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
|
||||
export { Response } from '@/sdk/logic-function/response';
|
||||
export type { ResponseInit } from '@/sdk/logic-function/response';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type LogicFunctionHttpResponse } from 'twenty-shared/types';
|
||||
|
||||
export type ResponseInit = {
|
||||
status?: number;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export class Response implements LogicFunctionHttpResponse {
|
||||
readonly __twentyHttpResponse = true as const;
|
||||
readonly body: unknown;
|
||||
readonly status?: number;
|
||||
readonly headers?: Record<string, string>;
|
||||
|
||||
constructor(body: unknown, init?: ResponseInit) {
|
||||
this.body = body;
|
||||
this.status = init?.status;
|
||||
this.headers = init?.headers;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { LOGIC_FUNCTION_HTTP_RESPONSE_MARKER } from 'twenty-shared/types';
|
||||
|
||||
import { buildRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
|
||||
|
||||
describe('buildRouteTriggerResponse', () => {
|
||||
it('wraps a plain body with status 200 and no headers', () => {
|
||||
expect(buildRouteTriggerResponse({ message: 'hi' })).toEqual({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: { message: 'hi' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through null/undefined as a 200 with that body', () => {
|
||||
expect(buildRouteTriggerResponse(null)).toEqual({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads status, headers and body from a wrapped response', () => {
|
||||
const data = {
|
||||
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
|
||||
body: '<h1>Hi</h1>',
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
};
|
||||
|
||||
expect(buildRouteTriggerResponse(data)).toEqual({
|
||||
statusCode: 201,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
body: '<h1>Hi</h1>',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults a wrapped response without status/headers to 200 and {}', () => {
|
||||
const data = {
|
||||
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
|
||||
body: { ok: true },
|
||||
};
|
||||
|
||||
expect(buildRouteTriggerResponse(data)).toEqual({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: { ok: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
-3
@@ -5,7 +5,7 @@ import { Request } from 'express';
|
||||
import { match } from 'path-to-regexp';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { HTTPMethod } from 'twenty-shared/types';
|
||||
import { HTTPMethod, isLogicFunctionHttpResponse } from 'twenty-shared/types';
|
||||
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
@@ -26,6 +26,26 @@ import {
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export type RouteTriggerResponse = {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
export const buildRouteTriggerResponse = (
|
||||
data: unknown,
|
||||
): RouteTriggerResponse => {
|
||||
if (isLogicFunctionHttpResponse(data)) {
|
||||
return {
|
||||
statusCode: data.status ?? 200,
|
||||
headers: data.headers ?? {},
|
||||
body: data.body,
|
||||
};
|
||||
}
|
||||
|
||||
return { statusCode: 200, headers: {}, body: data };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RouteTriggerService {
|
||||
private readonly logger = new Logger(RouteTriggerService.name);
|
||||
@@ -223,7 +243,7 @@ export class RouteTriggerService {
|
||||
}
|
||||
|
||||
if (!isDefined(result)) {
|
||||
return result;
|
||||
return buildRouteTriggerResponse(result);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
@@ -233,6 +253,6 @@ export class RouteTriggerService {
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
return buildRouteTriggerResponse(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
+137
-30
@@ -1,20 +1,29 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
|
||||
|
||||
import { type Request } from 'express';
|
||||
import { type Response } from 'express';
|
||||
import { HTTPMethod } from 'twenty-shared/types';
|
||||
|
||||
import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
|
||||
import { RouteTriggerController } from 'src/engine/metadata-modules/route-trigger/route-trigger.controller';
|
||||
|
||||
const createResponseMock = () => {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
return {
|
||||
status: jest.fn(),
|
||||
setHeader: jest.fn((key: string, value: string) => {
|
||||
headers[key.toLowerCase()] = value;
|
||||
}),
|
||||
getHeader: jest.fn((key: string) => headers[key.toLowerCase()]),
|
||||
send: jest.fn(),
|
||||
json: jest.fn(),
|
||||
} as unknown as Response;
|
||||
};
|
||||
|
||||
describe('RouteTriggerController', () => {
|
||||
let controller: RouteTriggerController;
|
||||
const handle = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
const routeTriggerService = {
|
||||
handle,
|
||||
} as unknown as RouteTriggerService;
|
||||
const routeTriggerService = { handle } as unknown as RouteTriggerService;
|
||||
|
||||
controller = new RouteTriggerController(routeTriggerService);
|
||||
});
|
||||
@@ -27,34 +36,132 @@ describe('RouteTriggerController', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('response status code', () => {
|
||||
it.each([
|
||||
['get', RouteTriggerController.prototype.get],
|
||||
['post', RouteTriggerController.prototype.post],
|
||||
['put', RouteTriggerController.prototype.put],
|
||||
['patch', RouteTriggerController.prototype.patch],
|
||||
['delete', RouteTriggerController.prototype.delete],
|
||||
])('should respond with 200 for %s', (_method, handler) => {
|
||||
const httpCode = Reflect.getMetadata(HTTP_CODE_METADATA, handler);
|
||||
it('delegates to the service with the POST http method and applies status 200', async () => {
|
||||
const request = { path: '/s/webhooks/google/leads' } as never;
|
||||
const response = createResponseMock();
|
||||
|
||||
expect(httpCode).toBe(HttpStatus.OK);
|
||||
handle.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: { ok: true },
|
||||
});
|
||||
|
||||
await controller.post(request, response);
|
||||
|
||||
expect(handle).toHaveBeenCalledWith({
|
||||
request,
|
||||
httpMethod: HTTPMethod.POST,
|
||||
});
|
||||
expect(response.status).toHaveBeenCalledWith(200);
|
||||
expect(response.json).toHaveBeenCalledWith({ ok: true });
|
||||
});
|
||||
|
||||
describe('post', () => {
|
||||
it('should delegate to the service with the POST http method', async () => {
|
||||
const request = { path: '/s/webhooks/google/leads' } as Request;
|
||||
const expectedResult = {};
|
||||
it('applies the status code and allow-listed headers from the service result', async () => {
|
||||
const response = createResponseMock();
|
||||
|
||||
handle.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.post(request);
|
||||
|
||||
expect(handle).toHaveBeenCalledWith({
|
||||
request,
|
||||
httpMethod: HTTPMethod.POST,
|
||||
});
|
||||
expect(result).toBe(expectedResult);
|
||||
handle.mockResolvedValue({
|
||||
statusCode: 201,
|
||||
headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' },
|
||||
body: '<h1>Hi</h1>',
|
||||
});
|
||||
|
||||
await controller.get({} as never, response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(201);
|
||||
expect(response.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Type',
|
||||
'text/html',
|
||||
);
|
||||
expect(response.setHeader).toHaveBeenCalledWith(
|
||||
'Cache-Control',
|
||||
'no-store',
|
||||
);
|
||||
expect(response.send).toHaveBeenCalledWith('<h1>Hi</h1>');
|
||||
});
|
||||
|
||||
it('drops headers that are not in the allow-list', async () => {
|
||||
const response = createResponseMock();
|
||||
|
||||
handle.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html',
|
||||
'Set-Cookie': 'session=abc',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'X-Custom': 'foo',
|
||||
},
|
||||
body: '<h1>Hi</h1>',
|
||||
});
|
||||
|
||||
await controller.get({} as never, response);
|
||||
|
||||
expect(response.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Type',
|
||||
'text/html',
|
||||
);
|
||||
expect(response.setHeader).not.toHaveBeenCalledWith(
|
||||
'Set-Cookie',
|
||||
'session=abc',
|
||||
);
|
||||
expect(response.setHeader).not.toHaveBeenCalledWith(
|
||||
'Access-Control-Allow-Origin',
|
||||
'*',
|
||||
);
|
||||
expect(response.setHeader).not.toHaveBeenCalledWith('X-Custom', 'foo');
|
||||
});
|
||||
|
||||
it('sends an empty response when the body is nil', async () => {
|
||||
const response = createResponseMock();
|
||||
|
||||
handle.mockResolvedValue({ statusCode: 200, headers: {}, body: null });
|
||||
|
||||
await controller.get({} as never, response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(200);
|
||||
expect(response.send).toHaveBeenCalledWith();
|
||||
expect(response.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults a string body content-type to text/plain when none is set', async () => {
|
||||
const response = createResponseMock();
|
||||
|
||||
handle.mockResolvedValue({ statusCode: 200, headers: {}, body: 'plain' });
|
||||
|
||||
await controller.get({} as never, response);
|
||||
|
||||
expect(response.setHeader).toHaveBeenCalledWith(
|
||||
'content-type',
|
||||
'text/plain',
|
||||
);
|
||||
expect(response.send).toHaveBeenCalledWith('plain');
|
||||
});
|
||||
|
||||
it('sends an object body as JSON when no content-type is set', async () => {
|
||||
const response = createResponseMock();
|
||||
|
||||
handle.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: { ok: true },
|
||||
});
|
||||
|
||||
await controller.get({} as never, response);
|
||||
|
||||
expect(response.json).toHaveBeenCalledWith({ ok: true });
|
||||
});
|
||||
|
||||
it('pre-serializes an object body when a custom content-type is set', async () => {
|
||||
const response = createResponseMock();
|
||||
|
||||
handle.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
body: { ok: true },
|
||||
});
|
||||
|
||||
await controller.get({} as never, response);
|
||||
|
||||
expect(response.send).toHaveBeenCalledWith(JSON.stringify({ ok: true }));
|
||||
expect(response.json).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+94
-34
@@ -2,23 +2,34 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { Request } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
import { HTTPMethod } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { RouteTriggerRestApiExceptionFilter } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter';
|
||||
import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
|
||||
import {
|
||||
RouteTriggerResponse,
|
||||
RouteTriggerService,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
|
||||
|
||||
const ALLOWED_RESPONSE_HEADERS = new Set([
|
||||
'content-type',
|
||||
'content-language',
|
||||
'content-disposition',
|
||||
'cache-control',
|
||||
'retry-after',
|
||||
]);
|
||||
|
||||
@Controller('s')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@@ -27,47 +38,96 @@ export class RouteTriggerController {
|
||||
constructor(private readonly routeTriggerService: RouteTriggerService) {}
|
||||
|
||||
@Get('*path')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async get(@Req() request: Request) {
|
||||
return await this.routeTriggerService.handle({
|
||||
request,
|
||||
httpMethod: HTTPMethod.GET,
|
||||
});
|
||||
async get(@Req() request: Request, @Res() response: Response) {
|
||||
this.sendResponse(
|
||||
response,
|
||||
await this.routeTriggerService.handle({
|
||||
request,
|
||||
httpMethod: HTTPMethod.GET,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('*path')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async post(@Req() request: Request) {
|
||||
return await this.routeTriggerService.handle({
|
||||
request,
|
||||
httpMethod: HTTPMethod.POST,
|
||||
});
|
||||
async post(@Req() request: Request, @Res() response: Response) {
|
||||
this.sendResponse(
|
||||
response,
|
||||
await this.routeTriggerService.handle({
|
||||
request,
|
||||
httpMethod: HTTPMethod.POST,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Put('*path')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async put(@Req() request: Request) {
|
||||
return await this.routeTriggerService.handle({
|
||||
request,
|
||||
httpMethod: HTTPMethod.PUT,
|
||||
});
|
||||
async put(@Req() request: Request, @Res() response: Response) {
|
||||
this.sendResponse(
|
||||
response,
|
||||
await this.routeTriggerService.handle({
|
||||
request,
|
||||
httpMethod: HTTPMethod.PUT,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('*path')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async patch(@Req() request: Request) {
|
||||
return await this.routeTriggerService.handle({
|
||||
request,
|
||||
httpMethod: HTTPMethod.PATCH,
|
||||
});
|
||||
async patch(@Req() request: Request, @Res() response: Response) {
|
||||
this.sendResponse(
|
||||
response,
|
||||
await this.routeTriggerService.handle({
|
||||
request,
|
||||
httpMethod: HTTPMethod.PATCH,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('*path')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async delete(@Req() request: Request) {
|
||||
return await this.routeTriggerService.handle({
|
||||
request,
|
||||
httpMethod: HTTPMethod.DELETE,
|
||||
});
|
||||
async delete(@Req() request: Request, @Res() response: Response) {
|
||||
this.sendResponse(
|
||||
response,
|
||||
await this.routeTriggerService.handle({
|
||||
request,
|
||||
httpMethod: HTTPMethod.DELETE,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private sendResponse(
|
||||
response: Response,
|
||||
{ statusCode, headers, body }: RouteTriggerResponse,
|
||||
) {
|
||||
response.status(statusCode);
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
|
||||
response.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDefined(body)) {
|
||||
response.send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const hasContentType = isDefined(response.getHeader('content-type'));
|
||||
|
||||
if (typeof body === 'string') {
|
||||
if (!hasContentType) {
|
||||
response.setHeader('content-type', 'text/plain');
|
||||
}
|
||||
|
||||
response.send(body);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasContentType) {
|
||||
response.send(JSON.stringify(body));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
response.json(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export const LOGIC_FUNCTION_HTTP_RESPONSE_MARKER = '__twentyHttpResponse';
|
||||
|
||||
export type LogicFunctionHttpResponse = {
|
||||
__twentyHttpResponse: true;
|
||||
body: unknown;
|
||||
status?: number;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
const isValidHttpStatusCode = (value: unknown): value is number =>
|
||||
typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 100 &&
|
||||
value <= 599;
|
||||
|
||||
const isStringRecord = (value: unknown): value is Record<string, string> =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every((entry) => typeof entry === 'string');
|
||||
|
||||
export const isLogicFunctionHttpResponse = (
|
||||
value: unknown,
|
||||
): value is LogicFunctionHttpResponse => {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
candidate[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER] === true &&
|
||||
(candidate.status === undefined ||
|
||||
isValidHttpStatusCode(candidate.status)) &&
|
||||
(candidate.headers === undefined || isStringRecord(candidate.headers))
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
isLogicFunctionHttpResponse,
|
||||
LOGIC_FUNCTION_HTTP_RESPONSE_MARKER,
|
||||
} from '../LogicFunctionResponse';
|
||||
|
||||
describe('isLogicFunctionHttpResponse', () => {
|
||||
it('returns true when the marker is present and true', () => {
|
||||
expect(
|
||||
isLogicFunctionHttpResponse({
|
||||
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
|
||||
body: { ok: true },
|
||||
status: 201,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a plain object with body/status keys but no marker', () => {
|
||||
expect(isLogicFunctionHttpResponse({ body: 'x', status: 200 })).toBe(false);
|
||||
});
|
||||
|
||||
it.each([null, undefined, 'string', 42, true])(
|
||||
'returns false for non-object value %p',
|
||||
(value) => {
|
||||
expect(isLogicFunctionHttpResponse(value)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns false when the marker is not strictly true', () => {
|
||||
expect(
|
||||
isLogicFunctionHttpResponse({
|
||||
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: 'yes',
|
||||
body: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when status and headers are absent', () => {
|
||||
expect(
|
||||
isLogicFunctionHttpResponse({
|
||||
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
|
||||
body: { ok: true },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid status and headers', () => {
|
||||
expect(
|
||||
isLogicFunctionHttpResponse({
|
||||
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
|
||||
body: 'ok',
|
||||
status: 204,
|
||||
headers: { 'Content-Type': 'text/plain', 'X-Custom': 'foo' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a string', 'oops'],
|
||||
['NaN', Number.NaN],
|
||||
['Infinity', Number.POSITIVE_INFINITY],
|
||||
['a non-integer', 200.5],
|
||||
['below the http range', 99],
|
||||
['above the http range', 600],
|
||||
])('returns false when status is %s', (_label, status) => {
|
||||
expect(
|
||||
isLogicFunctionHttpResponse({
|
||||
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
|
||||
body: null,
|
||||
status,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a string', 'oops'],
|
||||
['an array', ['a', 'b']],
|
||||
['null', null],
|
||||
['a record with a non-string value', { 'Content-Length': 42 }],
|
||||
])('returns false when headers is %s', (_label, headers) => {
|
||||
expect(
|
||||
isLogicFunctionHttpResponse({
|
||||
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
|
||||
body: null,
|
||||
headers,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,11 @@ export type { IsGreaterOrEqual } from './IsGreaterOrEqual.type';
|
||||
export type { IsNever } from './IsNever.type';
|
||||
export type { IsSerializedRelation } from './IsSerializedRelation.type';
|
||||
export type { LogicFunctionEvent } from './LogicFunctionEvent';
|
||||
export type { LogicFunctionHttpResponse } from './LogicFunctionResponse';
|
||||
export {
|
||||
LOGIC_FUNCTION_HTTP_RESPONSE_MARKER,
|
||||
isLogicFunctionHttpResponse,
|
||||
} from './LogicFunctionResponse';
|
||||
export { MessageChannelContactAutoCreationPolicy } from './MessageChannelContactAutoCreationPolicy';
|
||||
export { MessageChannelPendingGroupEmailsAction } from './MessageChannelPendingGroupEmailsAction';
|
||||
export { MessageChannelSyncStage } from './MessageChannelSyncStage';
|
||||
|
||||
Reference in New Issue
Block a user