followup 18044 (#18213)

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
nitin
2026-02-26 05:25:57 +05:30
committed by GitHub
parent 0b4bf97f35
commit a9649b06d5
5 changed files with 584 additions and 488 deletions
@@ -1,104 +1,122 @@
import { type MockedResponse } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { activityTargetableEntityArrayState } from '@/activities/states/activityTargetableEntityArrayState';
import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import gql from 'graphql-tag';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { mockedTasks } from '~/testing/mock-data/tasks';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
const mockedDate = '2024-03-15T12:00:00.000Z';
const toISOStringMock = jest.fn(() => mockedDate);
global.Date.prototype.toISOString = toISOStringMock;
const mockCreateOneNote = jest.fn();
const mockCreateOneNoteTarget = jest.fn();
const { id, title, bodyV2, status, dueAt } = mockedTasks[0];
const mockedActivity = {
id,
title,
bodyV2,
status,
dueAt,
updatedAt: mockedDate,
};
jest.mock('@/object-record/hooks/useCreateOneRecord', () => ({
useCreateOneRecord: ({
objectNameSingular,
}: {
objectNameSingular: string;
}) =>
objectNameSingular === CoreObjectNameSingular.NoteTarget
? { createOneRecord: mockCreateOneNoteTarget }
: { createOneRecord: mockCreateOneNote },
}));
const mocks: MockedResponse[] = [
{
request: {
query: gql`
mutation CreateOneActivity($input: ActivityCreateInput!) {
createActivity(data: $input) {
__typename
createdAt
reminderAt
authorId
title
status
updatedAt
body
dueAt
type
id
assigneeId
}
}
`,
variables: {
input: mockedActivity,
},
},
result: jest.fn(() => ({
data: {
createActivity: {
...mockedActivity,
__typename: 'Activity',
assigneeId: '',
authorId: '1',
reminderAt: null,
createdAt: mockedDate,
},
},
})),
},
];
const mockOpenRecordInCommandMenu = jest.fn();
jest.mock('@/command-menu/hooks/useOpenRecordInCommandMenu', () => ({
useOpenRecordInCommandMenu: () => ({
openRecordInCommandMenu: mockOpenRecordInCommandMenu,
}),
}));
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
apolloMocks: [],
});
const mockObjectMetadataItems = generatedMockObjectMetadataItems;
const fakeNoteId = 'fake-note-id';
describe('useOpenCreateActivityDrawer', () => {
beforeEach(() => {
jotaiStore.set(objectMetadataItemsState.atom, mockObjectMetadataItems);
jest.clearAllMocks();
mockCreateOneNote.mockResolvedValue({ id: fakeNoteId });
mockCreateOneNoteTarget.mockResolvedValue({
id: 'fake-note-target-id',
});
});
it('works as expected', async () => {
it('should create a note and note target then open the record in the command menu', async () => {
const { result } = renderHook(
() => {
const openActivityRightDrawer = useOpenCreateActivityDrawer({
() =>
useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Note,
});
const viewableRecordId = useAtomStateValue(viewableRecordIdState);
return {
openActivityRightDrawer,
viewableRecordId,
};
},
{
wrapper: Wrapper,
},
}),
{ wrapper: Wrapper },
);
expect(result.current.viewableRecordId).toBeNull();
await act(async () => {
result.current.openActivityRightDrawer({
await result.current({
targetableObjects: [],
});
});
expect(mockCreateOneNote).toHaveBeenCalledWith({
position: 'last',
});
expect(mockCreateOneNoteTarget).toHaveBeenCalledWith({
noteId: fakeNoteId,
});
expect(mockOpenRecordInCommandMenu).toHaveBeenCalledWith({
recordId: fakeNoteId,
objectNameSingular: CoreObjectNameSingular.Note,
isNewRecord: true,
});
expect(jotaiStore.get(viewableRecordIdState.atom)).toBe(fakeNoteId);
expect(jotaiStore.get(viewableRecordNameSingularState.atom)).toBe(
CoreObjectNameSingular.Note,
);
expect(jotaiStore.get(activityTargetableEntityArrayState.atom)).toEqual([]);
expect(jotaiStore.get(isUpsertingActivityInDBState.atom)).toBe(false);
});
it('should create a note target with the targetable object relation when targets are provided', async () => {
const targetableObjects = [
{
id: 'company-id',
targetObjectNameSingular: CoreObjectNameSingular.Company,
},
];
const { result } = renderHook(
() =>
useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Note,
}),
{ wrapper: Wrapper },
);
await act(async () => {
await result.current({
targetableObjects,
});
});
expect(mockCreateOneNote).toHaveBeenCalledWith({
position: 'last',
});
expect(mockCreateOneNoteTarget).toHaveBeenCalledWith(
expect.objectContaining({
noteId: fakeNoteId,
}),
);
expect(jotaiStore.get(activityTargetableEntityArrayState.atom)).toEqual(
targetableObjects,
);
});
});
@@ -129,14 +129,14 @@ describe('ClientService generated Twenty auth behavior', () => {
injectClientWrapper: (
output: string,
options: {
className: string;
apiClientName: string;
defaultUrl: string;
includeUploadFile: boolean;
},
) => Promise<void>;
}
).injectClientWrapper(temporaryGeneratedClientDirectory, {
className: 'MetadataApiClient',
apiClientName: 'MetadataApiClient',
defaultUrl: '`${process.env.TWENTY_API_URL}/metadata`',
includeUploadFile: true,
});
@@ -244,6 +244,7 @@ describe('ClientService generated Twenty auth behavior', () => {
it('refreshes and retries once after auth error when refresh callback is available', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
process.env.TWENTY_API_KEY = 'legacy-api-key-token';
const requestAccessTokenRefresh = vi
.fn<() => Promise<string>>()
@@ -289,7 +290,7 @@ describe('ClientService generated Twenty auth behavior', () => {
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(process.env.TWENTY_APP_ACCESS_TOKEN).toBe('fresh-token');
expect(process.env.TWENTY_API_KEY).toBe('fresh-token');
expect(process.env.TWENTY_API_KEY).toBe('legacy-api-key-token');
});
it('deduplicates concurrent token refresh requests', async () => {
@@ -1,16 +1,12 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import twentyClientTemplateSource from '@/cli/utilities/client/twenty-client-template.ts?raw';
import { generate } from '@genql/cli';
import * as fs from 'fs-extra';
import { join } from 'path';
import {
DEFAULT_API_KEY_NAME,
DEFAULT_API_URL_NAME,
DEFAULT_APP_ACCESS_TOKEN_NAME,
GENERATED_DIR,
} from 'twenty-shared/application';
import { DEFAULT_API_URL_NAME, GENERATED_DIR } from 'twenty-shared/application';
type ClientWrapperOptions = {
className: string;
apiClientName: string;
defaultUrl: string;
includeUploadFile: boolean;
};
@@ -21,6 +17,49 @@ const COMMON_SCALAR_TYPES = {
UUID: 'string',
};
const STRIPPED_TYPES_START = '// __STRIPPED_DURING_INJECTION_START__';
const STRIPPED_TYPES_END = '// __STRIPPED_DURING_INJECTION_END__';
const UPLOAD_FILE_START = '// __UPLOAD_FILE_START__';
const UPLOAD_FILE_END = '// __UPLOAD_FILE_END__';
const buildClientWrapperSource = (options: ClientWrapperOptions): string => {
let source = twentyClientTemplateSource;
source = source.replace(
new RegExp(
`${escapeRegExp(STRIPPED_TYPES_START)}[\\s\\S]*?${escapeRegExp(STRIPPED_TYPES_END)}\\n?`,
),
'',
);
source = source.replace("'__TWENTY_DEFAULT_URL__'", options.defaultUrl);
source = source.replace(/TwentyGeneratedClient/g, options.apiClientName);
if (!options.includeUploadFile) {
source = source.replace(
new RegExp(
`\\s*${escapeRegExp(UPLOAD_FILE_START)}[\\s\\S]*?${escapeRegExp(UPLOAD_FILE_END)}\\n?`,
),
'\n',
);
} else {
source = source.replace(
new RegExp(`\\s*${escapeRegExp(UPLOAD_FILE_START)}\\n`),
'\n',
);
source = source.replace(
new RegExp(`\\s*${escapeRegExp(UPLOAD_FILE_END)}\\n`),
'\n',
);
}
return `\n// ${options.apiClientName} (auto-injected by twenty-sdk)\n${source}`;
};
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export class ClientService {
private apiService: ApiService;
@@ -75,13 +114,13 @@ export class ClientService {
]);
await this.injectClientWrapper(join(tempPath, 'core'), {
className: 'CoreApiClient',
apiClientName: 'CoreApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\``,
includeUploadFile: false,
});
await this.injectClientWrapper(join(tempPath, 'metadata'), {
className: 'MetadataApiClient',
apiClientName: 'MetadataApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\``,
includeUploadFile: true,
});
@@ -133,408 +172,8 @@ export { MetadataApiClient } from './metadata/index';
output: string,
options: ClientWrapperOptions,
): Promise<void> {
const clientContent = this.buildClientWrapperTemplate(options);
const clientContent = buildClientWrapperSource(options);
await fs.appendFile(join(output, 'index.ts'), clientContent);
}
private buildClientWrapperTemplate(options: ClientWrapperOptions): string {
const { className, defaultUrl, includeUploadFile } = options;
const uploadFileMethod = includeUploadFile
? `
async uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string = 'application/octet-stream',
fieldMetadataUniversalIdentifier: string,
): Promise<{
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}> {
const form = new FormData();
form.append(
'operations',
JSON.stringify({
query: \`mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) {
uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url }
}\`,
variables: { file: null, fieldMetadataUniversalIdentifier },
}),
);
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
form.append('0', new Blob([fileBuffer], { type: contentType }), filename);
const result = await this.executeGraphqlRequestWithOptionalRefresh({
operation: form,
headers: {},
requestInit: {
method: 'POST',
},
});
if (result.errors) {
throw new GenqlError(result.errors, result.data);
}
const data = result.data as Record<string, unknown>;
return data.uploadFilesFieldFileByUniversalIdentifier as {
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}
}
`
: '';
return `
// ----------------------------------------------------
// ${className} (auto-injected)
// ----------------------------------------------------
const APP_ACCESS_TOKEN_ENV_KEY = '${DEFAULT_APP_ACCESS_TOKEN_NAME}';
const API_KEY_ENV_KEY = '${DEFAULT_API_KEY_NAME}';
type ${className}Options = ClientOptions
type ProcessEnvironment = Record<string, string | undefined>
type GraphqlError = {
message?: string;
extensions?: { code?: string };
}
type GraphqlResponsePayload = {
data?: Record<string, unknown>
errors?: GraphqlError[];
}
type GraphqlResponse = {
status: number;
statusText: string;
payload: GraphqlResponsePayload | null;
rawBody: string;
}
const getProcessEnvironment = (): ProcessEnvironment => {
const processObject = (globalThis as { process?: { env?: ProcessEnvironment } })
.process;
return processObject?.env ?? {};
}
const getTokenFromAuthorizationHeader = (
authorizationHeader: string | undefined,
): string | null => {
if (typeof authorizationHeader !== 'string') {
return null;
}
const trimmedAuthorizationHeader = authorizationHeader.trim();
if (trimmedAuthorizationHeader.length === 0) {
return null;
}
if (trimmedAuthorizationHeader === 'Bearer') {
return null;
}
if (trimmedAuthorizationHeader.startsWith('Bearer ')) {
return trimmedAuthorizationHeader.slice('Bearer '.length).trim();
}
return trimmedAuthorizationHeader;
}
const getTokenFromHeaders = (headers: HeadersInit | undefined): string | null => {
if (!headers) {
return null;
}
if (headers instanceof Headers) {
return getTokenFromAuthorizationHeader(headers.get('Authorization') ?? undefined);
}
if (Array.isArray(headers)) {
const matchedAuthorizationHeader = headers.find(
([headerName]) => headerName.toLowerCase() === 'authorization',
);
return getTokenFromAuthorizationHeader(matchedAuthorizationHeader?.[1]);
}
const headersRecord = headers as Record<string, string | undefined>;
return getTokenFromAuthorizationHeader(
headersRecord.Authorization ?? headersRecord.authorization,
);
}
const hasAuthenticationErrorInGraphqlPayload = (
payload: GraphqlResponsePayload | null,
): boolean => {
if (!payload?.errors) {
return false;
}
return payload.errors.some((error) => {
return (
error.extensions?.code === 'UNAUTHENTICATED' ||
error.message?.toLowerCase() === 'unauthorized'
);
});
}
const defaultOptions: ${className}Options = {
url: ${defaultUrl},
headers: {
'Content-Type': 'application/json',
},
}
export class ${className} {
private client: Client;
private url: string;
private requestOptions: RequestInit;
private headers: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
private fetchImplementation: typeof globalThis.fetch | null;
private authorizationToken: string | null;
private refreshAccessTokenPromise: Promise<string | null> | null = null;
constructor(options?: ${className}Options) {
const merged: ${className}Options = {
...defaultOptions,
...options,
}
const {
url,
headers,
fetch: customFetchImplementation,
fetcher: _fetcher,
batch: _batch,
...requestOptions
} = merged;
this.url = url ?? '';
this.requestOptions = requestOptions;
this.headers = headers ?? {};
this.fetchImplementation = customFetchImplementation ?? globalThis.fetch ?? null;
const processEnvironment = getProcessEnvironment();
const tokenFromHeaders = getTokenFromHeaders(
typeof headers === 'function' ? undefined : headers,
);
// Priority: explicit header > TWENTY_APP_ACCESS_TOKEN > TWENTY_API_KEY (legacy fallback).
this.authorizationToken =
tokenFromHeaders ??
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] ??
processEnvironment[API_KEY_ENV_KEY] ??
null;
this.client = createClient({
...merged,
headers: undefined,
fetcher: async (operation) =>
this.executeGraphqlRequestWithOptionalRefresh({
operation,
}),
});
}
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
return this.client.query(request);
}
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
return this.client.mutation(request);
}
${uploadFileMethod}
private async executeGraphqlRequestWithOptionalRefresh({
operation,
headers,
requestInit,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
}) {
const firstResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: this.authorizationToken,
});
if (this.shouldRefreshToken(firstResponse)) {
const refreshedAccessToken = await this.requestRefreshedAccessToken();
if (refreshedAccessToken) {
const retryResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: refreshedAccessToken,
});
return this.assertResponseIsSuccessful(retryResponse);
}
}
return this.assertResponseIsSuccessful(firstResponse);
}
private async executeGraphqlRequest({
operation,
headers,
requestInit,
token,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
token: string | null;
}): Promise<GraphqlResponse> {
if (!this.fetchImplementation) {
throw new Error(
'Global \`fetch\` function is not available, pass a fetch implementation to the Twenty client',
);
}
const resolvedHeaders = await this.resolveHeaders();
const requestHeaders = new Headers(resolvedHeaders);
if (headers) {
new Headers(headers).forEach((value, key) => requestHeaders.set(key, value));
}
if (operation instanceof FormData) {
requestHeaders.delete('Content-Type');
} else {
requestHeaders.set('Content-Type', 'application/json');
}
if (token) {
requestHeaders.set('Authorization', \`Bearer \${token}\`);
} else {
requestHeaders.delete('Authorization');
}
const response = await this.fetchImplementation.call(globalThis, this.url, {
...this.requestOptions,
...requestInit,
method: requestInit?.method ?? 'POST',
headers: requestHeaders,
body: operation instanceof FormData ? operation : JSON.stringify(operation),
});
const rawBody = await response.text();
let payload: GraphqlResponsePayload | null = null;
if (rawBody.trim().length > 0) {
try {
payload = JSON.parse(rawBody) as GraphqlResponsePayload;
} catch {
payload = null;
}
}
return {
status: response.status,
statusText: response.statusText,
payload,
rawBody,
}
}
private async resolveHeaders(): Promise<HeadersInit> {
if (typeof this.headers === 'function') {
return (await this.headers()) ?? {};
}
return this.headers ?? {};
}
private shouldRefreshToken(response: GraphqlResponse): boolean {
if (response.status === 401) {
return true;
}
return hasAuthenticationErrorInGraphqlPayload(response.payload);
}
private assertResponseIsSuccessful(response: GraphqlResponse) {
if (response.status < 200 || response.status >= 300) {
throw new Error(\`\${response.statusText}: \${response.rawBody}\`);
}
if (response.payload === null) {
throw new Error('Invalid JSON response');
}
return response.payload;
}
private async requestRefreshedAccessToken(): Promise<string | null> {
const refreshAccessTokenFunction = (
globalThis as {
frontComponentHostCommunicationApi?: {
requestAccessTokenRefresh?: () => Promise<string>
}
}
).frontComponentHostCommunicationApi?.requestAccessTokenRefresh;
if (typeof refreshAccessTokenFunction !== 'function') {
return null;
}
if (!this.refreshAccessTokenPromise) {
this.refreshAccessTokenPromise = refreshAccessTokenFunction()
.then((refreshedAccessToken) => {
if (
typeof refreshedAccessToken !== 'string' ||
refreshedAccessToken.length === 0
) {
return null;
}
this.setAuthorizationToken(refreshedAccessToken);
return refreshedAccessToken;
})
.catch((error) => {
console.error('Twenty client: token refresh failed', error);
return null;
})
.finally(() => {
this.refreshAccessTokenPromise = null;
});
}
return this.refreshAccessTokenPromise;
}
private setAuthorizationToken(token: string) {
this.authorizationToken = token;
const processEnvironment = getProcessEnvironment();
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] = token;
processEnvironment[API_KEY_ENV_KEY] = token;
}
}
`;
}
}
@@ -0,0 +1,434 @@
// Ambient type stubs for the genql-generated code this template gets
// injected into. They enable full typecheck/lint on this file.
// __STRIPPED_DURING_INJECTION_START__
type QueryGenqlSelection = Record<string, unknown>;
type MutationGenqlSelection = Record<string, unknown>;
type GraphqlOperation = Record<string, unknown>;
type ClientOptions = {
url?: string;
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
fetcher?: (
operation: GraphqlOperation | GraphqlOperation[],
) => Promise<unknown>;
fetch?: typeof globalThis.fetch;
batch?: unknown;
};
type Client = {
query: (
request: QueryGenqlSelection & { __name?: string },
) => Promise<unknown>;
mutation: (
request: MutationGenqlSelection & { __name?: string },
) => Promise<unknown>;
};
declare function createClient(options: ClientOptions): Client;
declare class GenqlError extends Error {
constructor(errors: unknown, data: unknown);
}
// __STRIPPED_DURING_INJECTION_END__
const APP_ACCESS_TOKEN_ENV_KEY = 'TWENTY_APP_ACCESS_TOKEN';
const API_KEY_ENV_KEY = 'TWENTY_API_KEY';
type TwentyGeneratedClientOptions = ClientOptions;
type ProcessEnvironment = Record<string, string | undefined>;
type GraphqlErrorPayloadEntry = {
message?: string;
extensions?: { code?: string };
};
type GraphqlResponsePayload = {
data?: Record<string, unknown>;
errors?: GraphqlErrorPayloadEntry[];
};
type GraphqlResponse = {
status: number;
statusText: string;
payload: GraphqlResponsePayload | null;
rawBody: string;
};
const getProcessEnvironment = (): ProcessEnvironment => {
const processObject = (
globalThis as { process?: { env?: ProcessEnvironment } }
).process;
return processObject?.env ?? {};
};
const getTokenFromAuthorizationHeader = (
authorizationHeader: string | undefined,
): string | null => {
if (typeof authorizationHeader !== 'string') {
return null;
}
const trimmedAuthorizationHeader = authorizationHeader.trim();
if (trimmedAuthorizationHeader.length === 0) {
return null;
}
if (trimmedAuthorizationHeader === 'Bearer') {
return null;
}
if (trimmedAuthorizationHeader.startsWith('Bearer ')) {
return trimmedAuthorizationHeader.slice('Bearer '.length).trim();
}
return trimmedAuthorizationHeader;
};
const getTokenFromHeaders = (
headers: HeadersInit | undefined,
): string | null => {
if (!headers) {
return null;
}
if (headers instanceof Headers) {
return getTokenFromAuthorizationHeader(
headers.get('Authorization') ?? undefined,
);
}
if (Array.isArray(headers)) {
const matchedAuthorizationHeader = headers.find(
([headerName]) => headerName.toLowerCase() === 'authorization',
);
return getTokenFromAuthorizationHeader(matchedAuthorizationHeader?.[1]);
}
const headersRecord = headers as Record<string, string | undefined>;
return getTokenFromAuthorizationHeader(
headersRecord.Authorization ?? headersRecord.authorization,
);
};
const hasAuthenticationErrorInGraphqlPayload = (
payload: GraphqlResponsePayload | null,
): boolean => {
if (!payload?.errors) {
return false;
}
return payload.errors.some((graphqlError) => {
return (
graphqlError.extensions?.code === 'UNAUTHENTICATED' ||
graphqlError.message?.toLowerCase() === 'unauthorized'
);
});
};
const defaultOptions: TwentyGeneratedClientOptions = {
url: '__TWENTY_DEFAULT_URL__',
headers: {
'Content-Type': 'application/json',
},
};
export class TwentyGeneratedClient {
private client: Client;
private url: string;
private requestOptions: RequestInit;
private headers: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
private fetchImplementation: typeof globalThis.fetch | null;
private authorizationToken: string | null;
private refreshAccessTokenPromise: Promise<string | null> | null = null;
constructor(options?: TwentyGeneratedClientOptions) {
const merged: TwentyGeneratedClientOptions = {
...defaultOptions,
...options,
};
const {
url,
headers,
fetch: customFetchImplementation,
fetcher: _fetcher,
batch: _batch,
...requestOptions
} = merged;
this.url = url ?? '';
this.requestOptions = requestOptions;
this.headers = headers ?? {};
this.fetchImplementation =
customFetchImplementation ?? globalThis.fetch ?? null;
const processEnvironment = getProcessEnvironment();
const tokenFromHeaders = getTokenFromHeaders(
typeof headers === 'function' ? undefined : headers,
);
// Priority: explicit header > app access token > api key (legacy).
this.authorizationToken =
tokenFromHeaders ??
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] ??
processEnvironment[API_KEY_ENV_KEY] ??
null;
this.client = createClient({
...merged,
headers: undefined,
fetcher: async (operation) =>
this.executeGraphqlRequestWithOptionalRefresh({
operation,
}),
});
}
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
return this.client.query(request);
}
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
return this.client.mutation(request);
}
// __UPLOAD_FILE_START__
async uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string = 'application/octet-stream',
fieldMetadataUniversalIdentifier: string,
): Promise<{
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}> {
const form = new FormData();
form.append(
'operations',
JSON.stringify({
query: `mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) {
uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url }
}`,
variables: {
file: null,
fieldMetadataUniversalIdentifier,
},
}),
);
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
form.append(
'0',
new Blob([fileBuffer as BlobPart], { type: contentType }),
filename,
);
const result = await this.executeGraphqlRequestWithOptionalRefresh({
operation: form,
headers: {},
requestInit: {
method: 'POST',
},
});
if (result.errors) {
throw new GenqlError(result.errors, result.data);
}
const data = result.data as Record<string, unknown>;
return data.uploadFilesFieldFileByUniversalIdentifier as {
id: string;
path: string;
size: number;
createdAt: string;
url: string;
};
}
// __UPLOAD_FILE_END__
private async executeGraphqlRequestWithOptionalRefresh({
operation,
headers,
requestInit,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
}) {
const firstResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: this.authorizationToken,
});
if (this.shouldRefreshToken(firstResponse)) {
const refreshedAccessToken = await this.requestRefreshedAccessToken();
if (refreshedAccessToken) {
const retryResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: refreshedAccessToken,
});
return this.assertResponseIsSuccessful(retryResponse);
}
}
return this.assertResponseIsSuccessful(firstResponse);
}
private async executeGraphqlRequest({
operation,
headers,
requestInit,
token,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
token: string | null;
}): Promise<GraphqlResponse> {
if (!this.fetchImplementation) {
throw new Error(
'Global `fetch` function is not available, ' +
'pass a fetch implementation to the Twenty client',
);
}
const resolvedHeaders = await this.resolveHeaders();
const requestHeaders = new Headers(resolvedHeaders);
if (headers) {
new Headers(headers).forEach((value, key) =>
requestHeaders.set(key, value),
);
}
if (operation instanceof FormData) {
requestHeaders.delete('Content-Type');
} else {
requestHeaders.set('Content-Type', 'application/json');
}
if (token) {
requestHeaders.set('Authorization', `Bearer ${token}`);
} else {
requestHeaders.delete('Authorization');
}
const response = await this.fetchImplementation.call(globalThis, this.url, {
...this.requestOptions,
...requestInit,
method: requestInit?.method ?? 'POST',
headers: requestHeaders,
body:
operation instanceof FormData ? operation : JSON.stringify(operation),
});
const rawBody = await response.text();
let payload: GraphqlResponsePayload | null = null;
if (rawBody.trim().length > 0) {
try {
payload = JSON.parse(rawBody) as GraphqlResponsePayload;
} catch {
payload = null;
}
}
return {
status: response.status,
statusText: response.statusText,
payload,
rawBody,
};
}
private async resolveHeaders(): Promise<HeadersInit> {
if (typeof this.headers === 'function') {
return (await this.headers()) ?? {};
}
return this.headers ?? {};
}
private shouldRefreshToken(response: GraphqlResponse): boolean {
if (response.status === 401) {
return true;
}
return hasAuthenticationErrorInGraphqlPayload(response.payload);
}
private assertResponseIsSuccessful(response: GraphqlResponse) {
if (response.status < 200 || response.status >= 300) {
throw new Error(`${response.statusText}: ${response.rawBody}`);
}
if (response.payload === null) {
throw new Error('Invalid JSON response');
}
return response.payload;
}
private async requestRefreshedAccessToken(): Promise<string | null> {
const refreshAccessTokenFunction = (
globalThis as {
frontComponentHostCommunicationApi?: {
requestAccessTokenRefresh?: () => Promise<string>;
};
}
).frontComponentHostCommunicationApi?.requestAccessTokenRefresh;
if (typeof refreshAccessTokenFunction !== 'function') {
return null;
}
if (!this.refreshAccessTokenPromise) {
this.refreshAccessTokenPromise = refreshAccessTokenFunction()
.then((refreshedAccessToken) => {
if (
typeof refreshedAccessToken !== 'string' ||
refreshedAccessToken.length === 0
) {
return null;
}
this.setAuthorizationToken(refreshedAccessToken);
return refreshedAccessToken;
})
.catch((refreshError: unknown) => {
console.error('Twenty client: token refresh failed', refreshError);
return null;
})
.finally(() => {
this.refreshAccessTokenPromise = null;
});
}
return this.refreshAccessTokenPromise;
}
private setAuthorizationToken(token: string) {
this.authorizationToken = token;
const processEnvironment = getProcessEnvironment();
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] = token;
}
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*?raw' {
const content: string;
export default content;
}