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:
martmull
2026-06-04 17:34:10 +02:00
committed by GitHub
parent d3a1781a59
commit e0d42323af
11 changed files with 514 additions and 67 deletions
@@ -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';