Move http node to backend (#15424)

Fixes https://github.com/twentyhq/twenty/issues/14491

Also adding an url validator.
This commit is contained in:
Thomas Trompette
2025-10-31 17:46:58 +01:00
committed by GitHub
parent 9f97be67b1
commit 71e7063fc4
20 changed files with 695 additions and 153 deletions
@@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const TEST_HTTP_REQUEST = gql`
mutation TestHttpRequest($input: TestHttpRequestInput!) {
testHttpRequest(input: $input) {
success
message
result
error
status
statusText
headers
}
}
`;
@@ -1,12 +1,25 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { type HttpRequestFormData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { useMutation } from '@apollo/client';
import { act, renderHook } from '@testing-library/react';
import React from 'react';
import { RecoilRoot } from 'recoil';
import { resolveInput } from 'twenty-shared/utils';
import { useTestHttpRequest } from '../useTestHttpRequest';
// Mock Apollo Client
jest.mock('@apollo/client', () => ({
...jest.requireActual('@apollo/client'),
useMutation: jest.fn(),
}));
jest.mock('@/object-metadata/hooks/useApolloCoreClient', () => ({
useApolloCoreClient: jest.fn(),
}));
// Mock the resolveInput function
jest.mock('twenty-shared/utils', () => ({
...jest.requireActual('twenty-shared/utils'),
resolveInput: jest.fn((input, context) => {
// For testing purposes, we'll actually do the replacement for simple cases
if (typeof input === 'string') {
@@ -57,13 +70,10 @@ jest.mock('twenty-shared/utils', () => ({
}),
}));
// Mock fetch
global.fetch = jest.fn();
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
describe('useTestHttpRequest', () => {
const actionId = 'test-action-id';
const mockApolloClient = {};
const mockMutate = jest.fn();
const mockFormData: HttpRequestFormData = {
url: 'https://api.example.com/users',
@@ -81,6 +91,8 @@ describe('useTestHttpRequest', () => {
beforeEach(() => {
jest.clearAllMocks();
(useApolloCoreClient as jest.Mock).mockReturnValue(mockApolloClient);
(useMutation as jest.Mock).mockReturnValue([mockMutate]);
});
it('should initialize with correct default values', () => {
@@ -93,15 +105,18 @@ describe('useTestHttpRequest', () => {
expect(result.current.httpRequestTestData).toBeDefined();
});
it('should handle successful GET request', async () => {
const mockResponse = {
status: 200,
statusText: 'OK',
json: jest.fn().mockResolvedValue({ id: 1, name: 'John' }),
headers: new Map([['content-type', 'application/json']]),
};
mockFetch.mockResolvedValueOnce(mockResponse as any);
it('should handle successful GET request with JSON response', async () => {
mockMutate.mockResolvedValueOnce({
data: {
testHttpRequest: {
success: true,
message:
'HTTP GET request to https://api.example.com/users completed successfully',
result: { id: 1, name: 'John' },
error: null,
},
},
});
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
@@ -111,15 +126,16 @@ describe('useTestHttpRequest', () => {
await result.current.testHttpRequest(mockFormData, mockVariableValues);
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/users',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'Bearer test-token-123',
}),
}),
);
expect(mockMutate).toHaveBeenCalledWith({
variables: {
input: {
url: 'https://api.example.com/users',
method: 'GET',
headers: { Authorization: 'Bearer test-token-123' },
body: undefined,
},
},
});
expect(result.current.isTesting).toBe(false);
expect(result.current.httpRequestTestData.output?.status).toBe(200);
@@ -127,23 +143,26 @@ describe('useTestHttpRequest', () => {
'{\n "id": 1,\n "name": "John"\n}',
);
expect(result.current.httpRequestTestData.output?.error).toBeUndefined();
expect(result.current.httpRequestTestData.language).toBe('json');
});
it('should handle POST request with body', async () => {
it('should handle successful POST request with body', async () => {
const postFormData: HttpRequestFormData = {
...mockFormData,
method: 'POST',
body: { name: 'Jane', email: 'jane@example.com' },
};
const mockResponse = {
status: 201,
statusText: 'Created',
json: jest.fn().mockResolvedValue({ id: 2, name: 'Jane' }),
headers: new Map([['content-type', 'application/json']]),
};
mockFetch.mockResolvedValueOnce(mockResponse as any);
mockMutate.mockResolvedValueOnce({
data: {
testHttpRequest: {
success: true,
message: 'HTTP POST request completed successfully',
result: { id: 2, name: 'Jane', email: 'jane@example.com' },
error: null,
},
},
});
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
@@ -153,26 +172,32 @@ describe('useTestHttpRequest', () => {
await result.current.testHttpRequest(postFormData, mockVariableValues);
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/users',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'Jane', email: 'jane@example.com' }),
}),
);
expect(mockMutate).toHaveBeenCalledWith({
variables: {
input: {
url: 'https://api.example.com/users',
method: 'POST',
headers: { Authorization: 'Bearer test-token-123' },
body: { name: 'Jane', email: 'jane@example.com' },
},
},
});
expect(result.current.httpRequestTestData.output?.status).toBe(201);
expect(result.current.httpRequestTestData.output?.status).toBe(200);
expect(result.current.httpRequestTestData.language).toBe('json');
});
it('should handle non-JSON responses', async () => {
const mockResponse = {
status: 200,
statusText: 'OK',
text: jest.fn().mockResolvedValue('Plain text response'),
headers: new Map([['content-type', 'text/plain']]),
};
mockFetch.mockResolvedValueOnce(mockResponse as any);
it('should handle string response from backend', async () => {
mockMutate.mockResolvedValueOnce({
data: {
testHttpRequest: {
success: true,
message: 'HTTP GET request completed successfully',
result: 'Plain text response',
error: null,
},
},
});
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
@@ -188,9 +213,18 @@ describe('useTestHttpRequest', () => {
expect(result.current.httpRequestTestData.language).toBe('plaintext');
});
it('should handle request errors', async () => {
const errorMessage = 'Network error';
mockFetch.mockRejectedValueOnce(new Error(errorMessage));
it('should handle backend errors (success=false)', async () => {
const errorMessage = 'HTTP request failed';
mockMutate.mockResolvedValueOnce({
data: {
testHttpRequest: {
success: false,
message: 'HTTP GET request to https://api.example.com/users failed',
result: null,
error: errorMessage,
},
},
});
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
@@ -206,6 +240,43 @@ describe('useTestHttpRequest', () => {
expect(result.current.httpRequestTestData.language).toBe('plaintext');
});
it('should handle GraphQL mutation errors', async () => {
const errorMessage = 'Network error';
mockMutate.mockRejectedValueOnce(new Error(errorMessage));
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
});
await act(async () => {
await result.current.testHttpRequest(mockFormData, mockVariableValues);
});
expect(result.current.isTesting).toBe(false);
expect(result.current.httpRequestTestData.output?.error).toBe(errorMessage);
expect(result.current.httpRequestTestData.output?.status).toBeUndefined();
expect(result.current.httpRequestTestData.language).toBe('plaintext');
});
it('should handle missing response data', async () => {
mockMutate.mockResolvedValueOnce({
data: null,
});
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
});
await act(async () => {
await result.current.testHttpRequest(mockFormData, mockVariableValues);
});
expect(result.current.isTesting).toBe(false);
expect(result.current.httpRequestTestData.output?.error).toBe(
'No response from server',
);
});
it('should set isTesting to true during request', async () => {
// Create a promise that we can control
let resolvePromise: (value: any) => void;
@@ -213,7 +284,7 @@ describe('useTestHttpRequest', () => {
resolvePromise = resolve;
});
mockFetch.mockReturnValueOnce(mockPromise as any);
mockMutate.mockReturnValueOnce(mockPromise);
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
@@ -230,10 +301,14 @@ describe('useTestHttpRequest', () => {
// Complete the request
await act(async () => {
resolvePromise!({
status: 200,
statusText: 'OK',
json: jest.fn().mockResolvedValue({}),
headers: new Map([['content-type', 'application/json']]),
data: {
testHttpRequest: {
success: true,
message: 'Success',
result: {},
error: null,
},
},
});
await mockPromise;
});
@@ -255,14 +330,16 @@ describe('useTestHttpRequest', () => {
'trigger.properties.after.name': 'Yo',
};
const mockResponse = {
status: 201,
statusText: 'Created',
json: jest.fn().mockResolvedValue({ success: true }),
headers: new Map([['content-type', 'application/json']]),
};
mockFetch.mockResolvedValueOnce(mockResponse as any);
mockMutate.mockResolvedValueOnce({
data: {
testHttpRequest: {
success: true,
message: 'Success',
result: { success: true },
error: null,
},
},
});
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
@@ -295,4 +372,61 @@ describe('useTestHttpRequest', () => {
},
);
});
it('should handle non-string error responses', async () => {
mockMutate.mockResolvedValueOnce({
data: {
testHttpRequest: {
success: false,
message: 'Request failed',
result: null,
error: {
code: 'ERR_CONNECTION_REFUSED',
details: 'Connection refused',
},
},
},
});
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
});
await act(async () => {
await result.current.testHttpRequest(mockFormData, mockVariableValues);
});
expect(result.current.httpRequestTestData.output?.error).toBe(
'{"code":"ERR_CONNECTION_REFUSED","details":"Connection refused"}',
);
});
it('should track request duration', async () => {
mockMutate.mockResolvedValueOnce({
data: {
testHttpRequest: {
success: true,
message: 'Success',
result: { id: 1 },
error: null,
},
},
});
const { result } = renderHook(() => useTestHttpRequest(actionId), {
wrapper,
});
await act(async () => {
await result.current.testHttpRequest(mockFormData, mockVariableValues);
});
expect(result.current.httpRequestTestData.output?.duration).toBeDefined();
expect(typeof result.current.httpRequestTestData.output?.duration).toBe(
'number',
);
expect(
result.current.httpRequestTestData.output?.duration,
).toBeGreaterThanOrEqual(0);
});
});
@@ -1,12 +1,20 @@
import { type HttpRequestFormData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import {
type HttpRequestBody,
type HttpRequestFormData,
} from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { TEST_HTTP_REQUEST } from '@/workflow/workflow-steps/workflow-actions/http-request-action/graphql/mutations/testHttpRequest';
import { httpRequestTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/http-request-action/states/httpRequestTestDataFamilyState';
import { type HttpRequestTestData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/types/HttpRequestTestData';
import { isMethodWithBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isMethodWithBody';
import { useMutation } from '@apollo/client';
import { isObject, isString } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { resolveInput } from 'twenty-shared/utils';
import { parseDataFromContentType } from 'twenty-shared/workflow';
import { type HttpRequestBody } from '../constants/HttpRequest';
import { isDefined, resolveInput } from 'twenty-shared/utils';
import {
type TestHttpRequestInput,
type TestHttpRequestMutation,
type TestHttpRequestMutationVariables,
} from '~/generated-metadata/graphql';
const convertFlatVariablesToNestedContext = (flatVariables: {
[variablePath: string]: any;
@@ -32,59 +40,18 @@ const convertFlatVariablesToNestedContext = (flatVariables: {
};
export const useTestHttpRequest = (actionId: string) => {
const apolloCoreClient = useApolloCoreClient();
const [isTesting, setIsTesting] = useState(false);
const [httpRequestTestData, setHttpRequestTestData] = useRecoilState(
httpRequestTestDataFamilyState(actionId),
);
const callFetchRequest = async (
url: string,
headers: Record<string, string>,
method: string,
body?: HttpRequestBody | string,
): Promise<HttpRequestTestData['output']> => {
const requestOptions: RequestInit = {
method,
headers: headers,
};
if (isMethodWithBody(method) && body !== undefined) {
const contentType = headers['content-type'];
requestOptions.body = parseDataFromContentType(body, contentType);
if (contentType === 'multipart/form-data') {
const headersCopy = { ...headers };
delete headersCopy['content-type'];
requestOptions.headers = { ...headersCopy };
}
}
const response = await fetch(url as string, requestOptions);
let responseData: string;
const contentType = response.headers.get('content-type');
if (contentType !== null && contentType.includes('application/json')) {
const jsonData = await response.json();
responseData = JSON.stringify(jsonData, null, 2);
} else {
responseData = await response.text();
}
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
return {
data: responseData,
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
error: undefined,
};
};
const [mutate] = useMutation<
TestHttpRequestMutation,
TestHttpRequestMutationVariables
>(TEST_HTTP_REQUEST, {
client: apolloCoreClient,
});
const testHttpRequest = async (
httpRequestFormData: HttpRequestFormData,
variableValues: { [variablePath: string]: any },
@@ -108,32 +75,54 @@ export const useTestHttpRequest = (actionId: string) => {
);
const substitutedBody: HttpRequestBody | string | undefined =
typeof substitutedBodyRaw === 'string' ||
(typeof substitutedBodyRaw === 'object' && substitutedBodyRaw !== null)
isString(substitutedBodyRaw) ||
(isObject(substitutedBodyRaw) && isDefined(substitutedBodyRaw))
? substitutedBodyRaw
: undefined;
const output = await callFetchRequest(
substitutedUrl as string,
substitutedHeaders as Record<string, string>,
httpRequestFormData.method,
substitutedBody,
);
const contentType = output?.headers?.['content-type'];
const language = contentType?.includes('application/json')
? 'json'
: 'plaintext';
const duration = Date.now() - startTime;
const outputWithDuration = {
...output,
duration,
const input: TestHttpRequestInput = {
url: substitutedUrl as string,
method: httpRequestFormData.method,
headers: substitutedHeaders as Record<string, string>,
body: substitutedBody,
};
setHttpRequestTestData((prev) => ({
...prev,
output: outputWithDuration,
language,
}));
const result = await mutate({
variables: { input },
});
const duration = Date.now() - startTime;
const response = result?.data?.testHttpRequest;
if (!response) {
throw new Error('No response from server');
}
if (response.success === true) {
const resultData = isString(response.result)
? response.result
: JSON.stringify(response.result, null, 2);
const language = isObject(response.result) ? 'json' : 'plaintext';
setHttpRequestTestData((prev) => ({
...prev,
output: {
data: resultData,
status: response.status ?? 200,
statusText: response.statusText ?? 'OK',
headers: response.headers ?? {},
duration,
error: undefined,
},
language,
}));
} else {
throw new Error(
isString(response.error)
? response.error
: JSON.stringify(response.error),
);
}
} catch (error) {
const duration = Date.now() - startTime;
const errorMessage =