From e0d42323af0ea35df094f6ed2f95d566cf5cf78c Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 4 Jun 2026 17:34:10 +0200 Subject: [PATCH] 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) --- .../extend/apps/logic/logic-functions.mdx | 27 +++ .../logic-function/__tests__/response.spec.ts | 33 ++++ .../src/sdk/logic-function/index.ts | 2 + .../src/sdk/logic-function/response.ts | 19 ++ .../__tests__/route-trigger.service.spec.ts | 49 +++++ .../triggers/route/route-trigger.service.ts | 26 ++- .../route-trigger.controller.spec.ts | 167 ++++++++++++++---- .../route-trigger/route-trigger.controller.ts | 128 ++++++++++---- .../src/types/LogicFunctionResponse.ts | 37 ++++ .../__tests__/LogicFunctionResponse.spec.ts | 88 +++++++++ packages/twenty-shared/src/types/index.ts | 5 + 11 files changed, 514 insertions(+), 67 deletions(-) create mode 100644 packages/twenty-sdk/src/sdk/logic-function/__tests__/response.spec.ts create mode 100644 packages/twenty-sdk/src/sdk/logic-function/response.ts create mode 100644 packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts create mode 100644 packages/twenty-shared/src/types/LogicFunctionResponse.ts create mode 100644 packages/twenty-shared/src/types/__tests__/LogicFunctionResponse.spec.ts diff --git a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx index af06862da7..1129ff82e8 100644 --- a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx +++ b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx @@ -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']`). +#### 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('

Hello

', { + 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` + + +The status code must be a valid HTTP status code (between 100 and 599). Response header names are matched case-insensitively. + + #### 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. diff --git a/packages/twenty-sdk/src/sdk/logic-function/__tests__/response.spec.ts b/packages/twenty-sdk/src/sdk/logic-function/__tests__/response.spec.ts new file mode 100644 index 0000000000..d6ebfb77ce --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/__tests__/response.spec.ts @@ -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('

Hi

', { + status: 201, + headers: { 'Content-Type': 'text/html' }, + }); + + expect(response.body).toBe('

Hi

'); + 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); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/logic-function/index.ts b/packages/twenty-sdk/src/sdk/logic-function/index.ts index 812605b566..f72aa89fee 100644 --- a/packages/twenty-sdk/src/sdk/logic-function/index.ts +++ b/packages/twenty-sdk/src/sdk/logic-function/index.ts @@ -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'; diff --git a/packages/twenty-sdk/src/sdk/logic-function/response.ts b/packages/twenty-sdk/src/sdk/logic-function/response.ts new file mode 100644 index 0000000000..fd51220768 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/response.ts @@ -0,0 +1,19 @@ +import { type LogicFunctionHttpResponse } from 'twenty-shared/types'; + +export type ResponseInit = { + status?: number; + headers?: Record; +}; + +export class Response implements LogicFunctionHttpResponse { + readonly __twentyHttpResponse = true as const; + readonly body: unknown; + readonly status?: number; + readonly headers?: Record; + + constructor(body: unknown, init?: ResponseInit) { + this.body = body; + this.status = init?.status; + this.headers = init?.headers; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts new file mode 100644 index 0000000000..582c1267b0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts @@ -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: '

Hi

', + status: 201, + headers: { 'Content-Type': 'text/html' }, + }; + + expect(buildRouteTriggerResponse(data)).toEqual({ + statusCode: 201, + headers: { 'Content-Type': 'text/html' }, + body: '

Hi

', + }); + }); + + 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 }, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts index 7b37ed30a9..49bd97b27f 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts @@ -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; + 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); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/route-trigger/__tests__/route-trigger.controller.spec.ts b/packages/twenty-server/src/engine/metadata-modules/route-trigger/__tests__/route-trigger.controller.spec.ts index 2b7fcd4c52..1f0c9a8d68 100644 --- a/packages/twenty-server/src/engine/metadata-modules/route-trigger/__tests__/route-trigger.controller.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/route-trigger/__tests__/route-trigger.controller.spec.ts @@ -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 = {}; + + 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: '

Hi

', }); + + 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('

Hi

'); + }); + + 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: '

Hi

', + }); + + 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(); }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts index dbfbd29a8a..869fb6212a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts @@ -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); } } diff --git a/packages/twenty-shared/src/types/LogicFunctionResponse.ts b/packages/twenty-shared/src/types/LogicFunctionResponse.ts new file mode 100644 index 0000000000..3a570bd30e --- /dev/null +++ b/packages/twenty-shared/src/types/LogicFunctionResponse.ts @@ -0,0 +1,37 @@ +export const LOGIC_FUNCTION_HTTP_RESPONSE_MARKER = '__twentyHttpResponse'; + +export type LogicFunctionHttpResponse = { + __twentyHttpResponse: true; + body: unknown; + status?: number; + headers?: Record; +}; + +const isValidHttpStatusCode = (value: unknown): value is number => + typeof value === 'number' && + Number.isInteger(value) && + value >= 100 && + value <= 599; + +const isStringRecord = (value: unknown): value is Record => + 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; + + return ( + candidate[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER] === true && + (candidate.status === undefined || + isValidHttpStatusCode(candidate.status)) && + (candidate.headers === undefined || isStringRecord(candidate.headers)) + ); +}; diff --git a/packages/twenty-shared/src/types/__tests__/LogicFunctionResponse.spec.ts b/packages/twenty-shared/src/types/__tests__/LogicFunctionResponse.spec.ts new file mode 100644 index 0000000000..bcf54dba2e --- /dev/null +++ b/packages/twenty-shared/src/types/__tests__/LogicFunctionResponse.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index ade278d81b..004d6e82da 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -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';