diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenCreateActivityDrawer.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenCreateActivityDrawer.test.tsx index 846910fcbd..0e4cd09030 100644 --- a/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenCreateActivityDrawer.test.tsx +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenCreateActivityDrawer.test.tsx @@ -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, + ); }); }); diff --git a/packages/twenty-sdk/src/cli/utilities/client/__tests__/clientServiceGeneratedClientAuth.test.ts b/packages/twenty-sdk/src/cli/utilities/client/__tests__/clientServiceGeneratedClientAuth.test.ts index 08fa4dd5c3..de5bf3f091 100644 --- a/packages/twenty-sdk/src/cli/utilities/client/__tests__/clientServiceGeneratedClientAuth.test.ts +++ b/packages/twenty-sdk/src/cli/utilities/client/__tests__/clientServiceGeneratedClientAuth.test.ts @@ -129,14 +129,14 @@ describe('ClientService generated Twenty auth behavior', () => { injectClientWrapper: ( output: string, options: { - className: string; + apiClientName: string; defaultUrl: string; includeUploadFile: boolean; }, ) => Promise; } ).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>() @@ -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 () => { diff --git a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts index 201bc39091..a14412127a 100644 --- a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts @@ -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 { - 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; - - 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 - -type GraphqlError = { - message?: string; - extensions?: { code?: string }; -} - -type GraphqlResponsePayload = { - data?: Record - 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; - - 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); - private fetchImplementation: typeof globalThis.fetch | null; - private authorizationToken: string | null; - private refreshAccessTokenPromise: Promise | 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(request: R & { __name?: string }) { - return this.client.query(request); - } - - mutation(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 { - 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 { - 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 { - const refreshAccessTokenFunction = ( - globalThis as { - frontComponentHostCommunicationApi?: { - requestAccessTokenRefresh?: () => Promise - } - } - ).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; - } -} - -`; - } } diff --git a/packages/twenty-sdk/src/cli/utilities/client/twenty-client-template.ts b/packages/twenty-sdk/src/cli/utilities/client/twenty-client-template.ts new file mode 100644 index 0000000000..936157d977 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/client/twenty-client-template.ts @@ -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; +type MutationGenqlSelection = Record; +type GraphqlOperation = Record; + +type ClientOptions = { + url?: string; + headers?: HeadersInit | (() => HeadersInit | Promise); + fetcher?: ( + operation: GraphqlOperation | GraphqlOperation[], + ) => Promise; + fetch?: typeof globalThis.fetch; + batch?: unknown; +}; + +type Client = { + query: ( + request: QueryGenqlSelection & { __name?: string }, + ) => Promise; + mutation: ( + request: MutationGenqlSelection & { __name?: string }, + ) => Promise; +}; + +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; + +type GraphqlErrorPayloadEntry = { + message?: string; + extensions?: { code?: string }; +}; + +type GraphqlResponsePayload = { + data?: Record; + 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; + + 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); + private fetchImplementation: typeof globalThis.fetch | null; + private authorizationToken: string | null; + private refreshAccessTokenPromise: Promise | 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(request: R & { __name?: string }) { + return this.client.query(request); + } + + mutation(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; + + 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 { + 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 { + 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 { + const refreshAccessTokenFunction = ( + globalThis as { + frontComponentHostCommunicationApi?: { + requestAccessTokenRefresh?: () => Promise; + }; + } + ).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; + } +} diff --git a/packages/twenty-sdk/src/vite-env.d.ts b/packages/twenty-sdk/src/vite-env.d.ts new file mode 100644 index 0000000000..88d404d0ae --- /dev/null +++ b/packages/twenty-sdk/src/vite-env.d.ts @@ -0,0 +1,4 @@ +declare module '*?raw' { + const content: string; + export default content; +}