Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80e675f430 | |||
| 29091ca369 | |||
| 5e71b7cb9c | |||
| a641fbe804 | |||
| f536c4d8b3 |
+72
@@ -64,6 +64,78 @@ describe('functionExecute E2E', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should execute a CoreApiClient-based function and return companies', async () => {
|
||||
const result = await functionExecute({
|
||||
appPath: APP_PATH,
|
||||
functionName: 'list-companies',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { functionName: 'list-companies', status: 'SUCCESS' },
|
||||
});
|
||||
|
||||
const data = (result as { success: true; data: { data: unknown } }).data
|
||||
.data as { companies: { edges: unknown[] } };
|
||||
|
||||
expect(data.companies).toBeDefined();
|
||||
expect(data.companies.edges).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"node": {
|
||||
"name": "Housecall Pro",
|
||||
},
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"name": "Odessa",
|
||||
},
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"name": "airSlate",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should execute a MetadataApiClient-based function and return objects', async () => {
|
||||
const result = await functionExecute({
|
||||
appPath: APP_PATH,
|
||||
functionName: 'list-objects',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { functionName: 'list-objects', status: 'SUCCESS' },
|
||||
});
|
||||
|
||||
const data = (result as { success: true; data: { data: unknown } }).data
|
||||
.data as { objects: { edges: unknown[] } };
|
||||
|
||||
expect(data.objects).toBeDefined();
|
||||
expect(data.objects.edges).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"node": {
|
||||
"nameSingular": "messageChannelMessageAssociation",
|
||||
},
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"nameSingular": "favorite",
|
||||
},
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"nameSingular": "company",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return FUNCTION_NOT_FOUND for a non-existent function name', async () => {
|
||||
const result = await functionExecute({
|
||||
appPath: APP_PATH,
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { CoreApiClient } from '@/clients';
|
||||
import { defineLogicFunction } from '@/sdk';
|
||||
|
||||
export const LIST_COMPANIES_UNIVERSAL_IDENTIFIER =
|
||||
'b3a7e2c1-4f89-4d12-a6e3-9c8b1d0f5e7a';
|
||||
|
||||
const listCompaniesHandler = async () => {
|
||||
const coreClient = new CoreApiClient();
|
||||
|
||||
const result = await coreClient.query({
|
||||
companies: {
|
||||
__args: { first: 3 },
|
||||
edges: { node: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return { companies: result.companies };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: LIST_COMPANIES_UNIVERSAL_IDENTIFIER,
|
||||
name: 'list-companies',
|
||||
timeoutSeconds: 10,
|
||||
handler: listCompaniesHandler,
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { MetadataApiClient } from '@/clients';
|
||||
import { defineLogicFunction } from '@/sdk';
|
||||
|
||||
export const LIST_OBJECTS_UNIVERSAL_IDENTIFIER =
|
||||
'c4d8f3a2-5e91-4b23-b7f4-0d9c2e1a6f8b';
|
||||
|
||||
const listObjectsHandler = async () => {
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
const result = await metadataClient.query({
|
||||
objects: {
|
||||
__args: { paging: { first: 3 } },
|
||||
edges: { node: { nameSingular: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return { objects: result.objects };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: LIST_OBJECTS_UNIVERSAL_IDENTIFIER,
|
||||
name: 'list-objects',
|
||||
timeoutSeconds: 10,
|
||||
handler: listObjectsHandler,
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import { buildApplication } from '@/cli/utilities/build/common/build-application
|
||||
import { synchronizeBuiltApplication } from '@/cli/utilities/build/common/synchronize-built-application';
|
||||
import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
|
||||
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
|
||||
import { ClientService } from '@/cli/utilities/client/client-service';
|
||||
import { runSafe } from '@/cli/utilities/run-safe';
|
||||
import { APP_ERROR_CODES, type CommandResult } from './types';
|
||||
|
||||
@@ -54,24 +53,6 @@ const innerAppBuild = async (
|
||||
filePaths,
|
||||
});
|
||||
|
||||
onProgress?.('Syncing application schema...');
|
||||
|
||||
const firstSyncResult = await synchronizeBuiltApplication({
|
||||
appPath,
|
||||
manifest,
|
||||
builtFileInfos: firstBuildResult.builtFileInfos,
|
||||
});
|
||||
|
||||
if (!firstSyncResult.success) {
|
||||
return firstSyncResult;
|
||||
}
|
||||
|
||||
onProgress?.('Generating API client...');
|
||||
|
||||
const clientService = new ClientService();
|
||||
|
||||
await clientService.generateCoreClient({ appPath });
|
||||
|
||||
onProgress?.('Running typecheck...');
|
||||
|
||||
const typecheckErrors = await runTypecheck(appPath);
|
||||
@@ -91,31 +72,23 @@ const innerAppBuild = async (
|
||||
};
|
||||
}
|
||||
|
||||
onProgress?.('Rebuilding with generated client...');
|
||||
onProgress?.('Syncing application...');
|
||||
|
||||
const finalBuildResult = await buildApplication({
|
||||
const syncResult = await synchronizeBuiltApplication({
|
||||
appPath,
|
||||
manifest,
|
||||
filePaths,
|
||||
builtFileInfos: firstBuildResult.builtFileInfos,
|
||||
});
|
||||
|
||||
onProgress?.('Syncing built files...');
|
||||
|
||||
const finalSyncResult = await synchronizeBuiltApplication({
|
||||
appPath,
|
||||
manifest,
|
||||
builtFileInfos: finalBuildResult.builtFileInfos,
|
||||
});
|
||||
|
||||
if (!finalSyncResult.success) {
|
||||
return finalSyncResult;
|
||||
if (!syncResult.success) {
|
||||
return syncResult;
|
||||
}
|
||||
|
||||
const outputDir = path.join(appPath, '.twenty', 'output');
|
||||
|
||||
const result: AppBuildResult = {
|
||||
outputDir,
|
||||
fileCount: finalBuildResult.builtFileInfos.size,
|
||||
fileCount: firstBuildResult.builtFileInfos.size,
|
||||
};
|
||||
|
||||
if (options.tarball) {
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
import { CoreApiClient } from '../index';
|
||||
|
||||
describe('CoreApiClient', () => {
|
||||
const TEST_API_URL = 'https://api.twenty.com';
|
||||
|
||||
let savedEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = process.env;
|
||||
process.env = {
|
||||
...savedEnv,
|
||||
TWENTY_API_URL: TEST_API_URL,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = savedEnv;
|
||||
});
|
||||
|
||||
// The internal fetcher wrapper expects the custom fetcher to return
|
||||
// the raw JSON response `{ data, errors }`. It then extracts `.data` and
|
||||
// throws CoreGraphqlError on errors. CoreApiClient's custom fetcher
|
||||
// returns the full payload from assertResponseIsSuccessful.
|
||||
const createMockFetch = (
|
||||
responseData: Record<string, unknown>,
|
||||
statusOverrides?: { status?: number; statusText?: string },
|
||||
) => {
|
||||
return vi.fn().mockResolvedValue({
|
||||
status: statusOverrides?.status ?? 200,
|
||||
statusText: statusOverrides?.statusText ?? 'OK',
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ data: responseData })),
|
||||
});
|
||||
};
|
||||
|
||||
const parseSentBody = (mockFetch: ReturnType<typeof vi.fn>) => {
|
||||
return JSON.parse(mockFetch.mock.calls[0]![1].body) as {
|
||||
query: string;
|
||||
variables: Record<string, unknown>;
|
||||
operationName?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const getSentHeaders = (
|
||||
mockFetch: ReturnType<typeof vi.fn>,
|
||||
callIndex = 0,
|
||||
) => {
|
||||
return new Headers(mockFetch.mock.calls[callIndex]![1].headers);
|
||||
};
|
||||
|
||||
const createClient = (
|
||||
mockFetch: ReturnType<typeof vi.fn>,
|
||||
options?: Partial<ConstructorParameters<typeof CoreApiClient>[0]>,
|
||||
) => {
|
||||
return new CoreApiClient({
|
||||
fetch: mockFetch,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
describe('query', () => {
|
||||
it('should produce a valid findMany query with pagination', async () => {
|
||||
const mockFetch = createMockFetch({ companies: { edges: [] } });
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.query({
|
||||
companies: {
|
||||
__args: { first: 20, after: 'cursor-xyz' },
|
||||
edges: { node: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toBe(
|
||||
'query ($v1:Int,$v2:String){companies(first:$v1,after:$v2){edges{node{id,name}}}}',
|
||||
);
|
||||
expect(body.variables).toEqual({ v1: 20, v2: 'cursor-xyz' });
|
||||
});
|
||||
|
||||
it('should produce a valid findMany query with filter and orderBy', async () => {
|
||||
const mockFetch = createMockFetch({ companies: { edges: [] } });
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.query({
|
||||
companies: {
|
||||
__args: {
|
||||
filter: { name: { eq: 'Acme' } },
|
||||
orderBy: [{ name: 'AscNullsFirst' }],
|
||||
first: 10,
|
||||
},
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toContain('$v1:CompaniesFilterInput');
|
||||
expect(body.query).toContain('$v2:[CompaniesOrderByInput]');
|
||||
expect(body.query).toContain('$v3:Int');
|
||||
expect(body.variables).toEqual({
|
||||
v1: { name: { eq: 'Acme' } },
|
||||
v2: [{ name: 'AscNullsFirst' }],
|
||||
v3: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should produce a valid findOne query', async () => {
|
||||
const mockFetch = createMockFetch({ company: { id: '1' } });
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.query({
|
||||
company: {
|
||||
__args: { filter: { id: { eq: 'abc-123' } } },
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toBe(
|
||||
'query ($v1:CompanyFilterInput){company(filter:$v1){id,name,createdAt}}',
|
||||
);
|
||||
expect(body.variables).toEqual({
|
||||
v1: { id: { eq: 'abc-123' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('should produce a valid query without args', async () => {
|
||||
const mockFetch = createMockFetch({ companies: { edges: [] } });
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.query({
|
||||
companies: {
|
||||
edges: { node: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toBe(
|
||||
'query {companies{edges{node{id,name}}}}',
|
||||
);
|
||||
expect(body.variables).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutation', () => {
|
||||
it('should produce a valid createOne mutation', async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
createCompany: { id: 'new-id' },
|
||||
});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.mutation({
|
||||
createCompany: {
|
||||
__args: { data: { name: 'Acme', employees: 50 } },
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toBe(
|
||||
'mutation ($v1:CompanyCreateInput!){createCompany(data:$v1){id,name}}',
|
||||
);
|
||||
expect(body.variables).toEqual({
|
||||
v1: { name: 'Acme', employees: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should produce a valid updateOne mutation', async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
updateCompany: { id: 'abc-123' },
|
||||
});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.mutation({
|
||||
updateCompany: {
|
||||
__args: {
|
||||
id: 'abc-123',
|
||||
data: { name: 'Updated Corp' },
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toBe(
|
||||
'mutation ($v1:UUID!,$v2:CompanyUpdateInput!){updateCompany(id:$v1,data:$v2){id}}',
|
||||
);
|
||||
expect(body.variables).toEqual({
|
||||
v1: 'abc-123',
|
||||
v2: { name: 'Updated Corp' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should produce a valid deleteOne mutation', async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
deleteCompany: { id: 'abc-123' },
|
||||
});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.mutation({
|
||||
deleteCompany: {
|
||||
__args: { id: 'abc-123' },
|
||||
id: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toBe(
|
||||
'mutation ($v1:UUID!){deleteCompany(id:$v1){id,deletedAt}}',
|
||||
);
|
||||
expect(body.variables).toEqual({ v1: 'abc-123' });
|
||||
});
|
||||
|
||||
it('should produce a valid destroyOne mutation', async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
destroyPerson: { id: 'p-1' },
|
||||
});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.mutation({
|
||||
destroyPerson: {
|
||||
__args: { id: 'p-1' },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toBe(
|
||||
'mutation ($v1:UUID!){destroyPerson(id:$v1){id}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should produce a valid restoreOne mutation', async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
restoreCompany: { id: 'abc-123' },
|
||||
});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.mutation({
|
||||
restoreCompany: {
|
||||
__args: { id: 'abc-123' },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toBe(
|
||||
'mutation ($v1:UUID!){restoreCompany(id:$v1){id}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should produce a valid updateMany mutation', async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
updateCompanies: [{ id: '1' }],
|
||||
});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.mutation({
|
||||
updateCompanies: {
|
||||
__args: {
|
||||
data: { name: 'Bulk Updated' },
|
||||
filter: { name: { eq: 'Old Name' } },
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toContain('$v1:CompaniesUpdateInput!');
|
||||
expect(body.query).toContain('$v2:CompaniesFilterInput!');
|
||||
});
|
||||
|
||||
it('should produce a valid deleteMany mutation', async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
deleteCompanies: [{ id: '1' }],
|
||||
});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.mutation({
|
||||
deleteCompanies: {
|
||||
__args: { filter: { name: { eq: 'test' } } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toContain('$v1:CompaniesFilterInput!');
|
||||
});
|
||||
|
||||
it('should produce a valid mergeMany mutation', async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
mergeCompanies: { id: 'merged' },
|
||||
});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.mutation({
|
||||
mergeCompanies: {
|
||||
__args: {
|
||||
ids: ['id-1', 'id-2'],
|
||||
conflictPriorityIndex: 0,
|
||||
dryRun: true,
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toContain('$v1:[UUID!]!');
|
||||
expect(body.query).toContain('$v2:Int!');
|
||||
expect(body.query).toContain('$v3:Boolean');
|
||||
expect(body.variables).toEqual({
|
||||
v1: ['id-1', 'id-2'],
|
||||
v2: 0,
|
||||
v3: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested field selections in mutations', async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
createCompany: {
|
||||
id: 'new-id',
|
||||
people: { edges: [{ node: { id: 'p-1' } }] },
|
||||
},
|
||||
});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.mutation({
|
||||
createCompany: {
|
||||
__args: { data: { name: 'Acme' } },
|
||||
id: true,
|
||||
people: {
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
name: { firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body = parseSentBody(mockFetch);
|
||||
|
||||
expect(body.query).toContain(
|
||||
'people{edges{node{id,name{firstName,lastName}}}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request configuration', () => {
|
||||
it('should default URL to TWENTY_API_URL/graphql from env', async () => {
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.query({ companies: { edges: { node: { id: true } } } });
|
||||
|
||||
expect(mockFetch.mock.calls[0]![0]).toBe(
|
||||
`${TEST_API_URL}/graphql`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow overriding the URL', async () => {
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch, {
|
||||
url: 'https://custom.api.com/gql',
|
||||
});
|
||||
|
||||
await client.query({ companies: { edges: { node: { id: true } } } });
|
||||
|
||||
expect(mockFetch.mock.calls[0]![0]).toBe(
|
||||
'https://custom.api.com/gql',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use POST method', async () => {
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.query({ companies: { edges: { node: { id: true } } } });
|
||||
|
||||
expect(mockFetch.mock.calls[0]![1].method).toBe('POST');
|
||||
});
|
||||
|
||||
it('should set Content-Type to application/json', async () => {
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.query({ companies: { edges: { node: { id: true } } } });
|
||||
|
||||
const headers = getSentHeaders(mockFetch);
|
||||
|
||||
expect(headers.get('Content-Type')).toBe('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication', () => {
|
||||
it('should set Authorization header from explicit Bearer token', async () => {
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch, {
|
||||
headers: { Authorization: 'Bearer my-token' },
|
||||
});
|
||||
|
||||
await client.query({ companies: { edges: { node: { id: true } } } });
|
||||
|
||||
const headers = getSentHeaders(mockFetch);
|
||||
|
||||
expect(headers.get('Authorization')).toBe('Bearer my-token');
|
||||
});
|
||||
|
||||
it('should pick up token from TWENTY_APP_ACCESS_TOKEN env var', async () => {
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'env-app-token';
|
||||
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
});
|
||||
|
||||
const headers = getSentHeaders(mockFetch);
|
||||
|
||||
expect(headers.get('Authorization')).toBe('Bearer env-app-token');
|
||||
});
|
||||
|
||||
it('should pick up token from TWENTY_API_KEY env var as fallback', async () => {
|
||||
delete process.env.TWENTY_APP_ACCESS_TOKEN;
|
||||
process.env.TWENTY_API_KEY = 'env-api-key';
|
||||
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
});
|
||||
|
||||
const headers = getSentHeaders(mockFetch);
|
||||
|
||||
expect(headers.get('Authorization')).toBe('Bearer env-api-key');
|
||||
});
|
||||
|
||||
it('should prioritize explicit header over env vars', async () => {
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'env-token';
|
||||
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch, {
|
||||
headers: { Authorization: 'Bearer explicit-token' },
|
||||
});
|
||||
|
||||
await client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
});
|
||||
|
||||
const headers = getSentHeaders(mockFetch);
|
||||
|
||||
expect(headers.get('Authorization')).toBe(
|
||||
'Bearer explicit-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set Authorization header when no token is available', async () => {
|
||||
delete process.env.TWENTY_APP_ACCESS_TOKEN;
|
||||
delete process.env.TWENTY_API_KEY;
|
||||
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
});
|
||||
|
||||
const headers = getSentHeaders(mockFetch);
|
||||
|
||||
expect(headers.get('Authorization')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('token refresh', () => {
|
||||
afterEach(() => {
|
||||
delete (globalThis as Record<string, unknown>)
|
||||
.frontComponentHostCommunicationApi;
|
||||
});
|
||||
|
||||
it('should retry with refreshed token on 401 response', async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ errors: [] })),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({ data: { company: { id: '1' } } }),
|
||||
),
|
||||
});
|
||||
|
||||
const mockRefresh = vi.fn().mockResolvedValue('refreshed-token');
|
||||
|
||||
(globalThis as Record<string, unknown>)
|
||||
.frontComponentHostCommunicationApi = {
|
||||
requestAccessTokenRefresh: mockRefresh,
|
||||
};
|
||||
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'expired';
|
||||
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
const result = await client.query({
|
||||
company: {
|
||||
__args: { filter: { id: { eq: 'abc' } } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
const retryHeaders = getSentHeaders(mockFetch, 1);
|
||||
|
||||
expect(retryHeaders.get('Authorization')).toBe(
|
||||
'Bearer refreshed-token',
|
||||
);
|
||||
// genql runtime unwraps .data from the payload
|
||||
expect(result).toEqual({ company: { id: '1' } });
|
||||
});
|
||||
|
||||
it('should retry on UNAUTHENTICATED GraphQL error', async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
errors: [
|
||||
{ extensions: { code: 'UNAUTHENTICATED' } },
|
||||
],
|
||||
}),
|
||||
),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({ data: { companies: [] } }),
|
||||
),
|
||||
});
|
||||
|
||||
(globalThis as Record<string, unknown>)
|
||||
.frontComponentHostCommunicationApi = {
|
||||
requestAccessTokenRefresh: vi
|
||||
.fn()
|
||||
.mockResolvedValue('new-token'),
|
||||
};
|
||||
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not retry when no refresh function is available', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ errors: [] })),
|
||||
});
|
||||
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await expect(
|
||||
client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
}),
|
||||
).rejects.toThrow('Unauthorized');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not retry when refresh returns empty string', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ errors: [] })),
|
||||
});
|
||||
|
||||
(globalThis as Record<string, unknown>)
|
||||
.frontComponentHostCommunicationApi = {
|
||||
requestAccessTokenRefresh: vi.fn().mockResolvedValue(''),
|
||||
};
|
||||
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await expect(
|
||||
client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
}),
|
||||
).rejects.toThrow('Unauthorized');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not retry when refresh throws', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ errors: [] })),
|
||||
});
|
||||
|
||||
(globalThis as Record<string, unknown>)
|
||||
.frontComponentHostCommunicationApi = {
|
||||
requestAccessTokenRefresh: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('refresh failed')),
|
||||
};
|
||||
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await expect(
|
||||
client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
}),
|
||||
).rejects.toThrow('Unauthorized');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('response handling', () => {
|
||||
it('should return unwrapped data on success', async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
createCompany: { id: 'new-id', name: 'Acme' },
|
||||
});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
const result = await client.mutation({
|
||||
createCompany: {
|
||||
__args: { data: { name: 'Acme' } },
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
// genql runtime unwraps .data from the payload
|
||||
expect(result).toEqual({
|
||||
createCompany: { id: 'new-id', name: 'Acme' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on non-2xx response with status text and body', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
text: () => Promise.resolve('{"errors":[{"message":"boom"}]}'),
|
||||
});
|
||||
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await expect(
|
||||
client.mutation({
|
||||
createCompany: {
|
||||
__args: { data: { name: 'Acme' } },
|
||||
id: true,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('Internal Server Error');
|
||||
});
|
||||
|
||||
it('should throw on empty response body', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: () => Promise.resolve(''),
|
||||
});
|
||||
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await expect(
|
||||
client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
}),
|
||||
).rejects.toThrow('Invalid JSON response');
|
||||
});
|
||||
|
||||
it('should throw on malformed JSON response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: () => Promise.resolve('not json'),
|
||||
});
|
||||
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await expect(
|
||||
client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
}),
|
||||
).rejects.toThrow('Invalid JSON response');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('should throw when fetch is not available', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = undefined as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
await expect(
|
||||
client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
}),
|
||||
).rejects.toThrow('fetch');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw for unrecognized argument names', async () => {
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch);
|
||||
|
||||
await expect(
|
||||
client.mutation({
|
||||
createCompany: {
|
||||
__args: { unknownArg: 'value' },
|
||||
id: true,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/Cannot infer GraphQL type/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('headers resolution', () => {
|
||||
it('should support static headers object', async () => {
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch, {
|
||||
headers: {
|
||||
'X-Custom': 'custom-value',
|
||||
Authorization: 'Bearer static',
|
||||
},
|
||||
});
|
||||
|
||||
await client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
});
|
||||
|
||||
const headers = getSentHeaders(mockFetch);
|
||||
|
||||
expect(headers.get('X-Custom')).toBe('custom-value');
|
||||
expect(headers.get('Authorization')).toBe('Bearer static');
|
||||
});
|
||||
|
||||
it('should support async headers function', async () => {
|
||||
const mockFetch = createMockFetch({});
|
||||
const client = createClient(mockFetch, {
|
||||
headers: async () => ({
|
||||
'X-Dynamic': 'dynamic-value',
|
||||
}),
|
||||
});
|
||||
|
||||
await client.query({
|
||||
companies: { edges: { node: { id: true } } },
|
||||
});
|
||||
|
||||
const headers = getSentHeaders(mockFetch);
|
||||
|
||||
expect(headers.get('X-Dynamic')).toBe('dynamic-value');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,488 @@
|
||||
// Stub — overwritten by `twenty app:build` or `twenty app:dev`
|
||||
export class CoreApiClient {}
|
||||
// @ts-nocheck
|
||||
// Core API client stub — fully self-contained, no genql dependency.
|
||||
// Uses convention-based type inference so logic functions work
|
||||
// without code generation. Overwritten by `twenty app:dev` for
|
||||
// full type safety during local development.
|
||||
import {
|
||||
createClient as createClientOriginal,
|
||||
generateGraphqlOperation,
|
||||
CoreGraphqlError,
|
||||
type FieldsSelection,
|
||||
type GraphqlOperation,
|
||||
type ClientOptions,
|
||||
} from './twenty-patches';
|
||||
|
||||
export type { FieldsSelection } from './twenty-patches';
|
||||
export { CoreGraphqlError };
|
||||
|
||||
// Empty roots — the type map is not used in stub mode.
|
||||
// Convention-based inference in generateGraphqlOperation handles
|
||||
// argument type resolution instead.
|
||||
const EMPTY_ROOT = { name: '', fields: {}, scalar: [] };
|
||||
|
||||
export type Query = {};
|
||||
export type QueryGenqlSelection = {};
|
||||
export type Mutation = {};
|
||||
export type MutationGenqlSelection = {};
|
||||
export type Subscription = {};
|
||||
export type SubscriptionGenqlSelection = {};
|
||||
|
||||
export interface Client {
|
||||
query<R extends QueryGenqlSelection>(
|
||||
request: R & { __name?: string },
|
||||
): Promise<FieldsSelection<Query, R>>;
|
||||
|
||||
mutation<R extends MutationGenqlSelection>(
|
||||
request: R & { __name?: string },
|
||||
): Promise<FieldsSelection<Mutation, R>>;
|
||||
}
|
||||
|
||||
export const createClient = function (options?: ClientOptions): Client {
|
||||
return createClientOriginal({
|
||||
url: undefined,
|
||||
|
||||
...options,
|
||||
queryRoot: EMPTY_ROOT,
|
||||
mutationRoot: EMPTY_ROOT,
|
||||
subscriptionRoot: EMPTY_ROOT,
|
||||
}) as any;
|
||||
};
|
||||
|
||||
export const everything = {
|
||||
__scalar: true,
|
||||
};
|
||||
|
||||
export type QueryResult<fields extends QueryGenqlSelection> = FieldsSelection<
|
||||
Query,
|
||||
fields
|
||||
>;
|
||||
export const generateQueryOp: (
|
||||
fields: QueryGenqlSelection & { __name?: string },
|
||||
) => GraphqlOperation = function (fields) {
|
||||
return generateGraphqlOperation('query', EMPTY_ROOT, fields as any);
|
||||
};
|
||||
|
||||
export type MutationResult<fields extends MutationGenqlSelection> =
|
||||
FieldsSelection<Mutation, fields>;
|
||||
export const generateMutationOp: (
|
||||
fields: MutationGenqlSelection & { __name?: string },
|
||||
) => GraphqlOperation = function (fields) {
|
||||
return generateGraphqlOperation('mutation', EMPTY_ROOT, fields as any);
|
||||
};
|
||||
|
||||
export type SubscriptionResult<fields extends SubscriptionGenqlSelection> =
|
||||
FieldsSelection<Subscription, fields>;
|
||||
export const generateSubscriptionOp: (
|
||||
fields: SubscriptionGenqlSelection & { __name?: string },
|
||||
) => GraphqlOperation = function (fields) {
|
||||
return generateGraphqlOperation(
|
||||
'subscription',
|
||||
EMPTY_ROOT,
|
||||
fields as any,
|
||||
);
|
||||
};
|
||||
|
||||
// CoreApiClient (auto-injected by twenty-sdk)
|
||||
|
||||
const APP_ACCESS_TOKEN_ENV_KEY = 'TWENTY_APP_ACCESS_TOKEN';
|
||||
const API_KEY_ENV_KEY = 'TWENTY_API_KEY';
|
||||
|
||||
type CoreApiClientOptions = 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 getDefaultUrl = (): string => {
|
||||
const processEnvironment = getProcessEnvironment();
|
||||
|
||||
return `${processEnvironment.TWENTY_API_URL ?? ''}/graphql`;
|
||||
};
|
||||
|
||||
export class CoreApiClient {
|
||||
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?: CoreApiClientOptions) {
|
||||
const merged: CoreApiClientOptions = {
|
||||
url: getDefaultUrl(),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...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,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 CoreGraphqlError(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;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Stub — overwritten by `twenty app:build` or `twenty app:dev`
|
||||
export type CoreSchema = {};
|
||||
@@ -0,0 +1,69 @@
|
||||
// @ts-nocheck
|
||||
// Creates a lightweight GraphQL client that uses convention-based
|
||||
// type inference. No genql dependency.
|
||||
import type { LinkedType, ClientOptions, GraphqlOperation } from './types';
|
||||
import { CoreGraphqlError } from './graphql-error';
|
||||
import { generateGraphqlOperation } from './generate-graphql-operation';
|
||||
|
||||
const createFetcher = (options: ClientOptions) => {
|
||||
const { fetcher: customFetcher } = options;
|
||||
|
||||
if (!customFetcher) {
|
||||
throw new Error(
|
||||
'CoreApiClient requires a custom fetcher. ' +
|
||||
'Direct URL-based fetching is not supported in stub mode.',
|
||||
);
|
||||
}
|
||||
|
||||
return async (operation: GraphqlOperation) => {
|
||||
const json = await customFetcher(operation);
|
||||
|
||||
if (json?.errors?.length) {
|
||||
throw new CoreGraphqlError(json.errors, json.data);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
};
|
||||
};
|
||||
|
||||
export const createClient = ({
|
||||
queryRoot,
|
||||
mutationRoot,
|
||||
subscriptionRoot,
|
||||
...options
|
||||
}: ClientOptions & {
|
||||
queryRoot?: LinkedType;
|
||||
mutationRoot?: LinkedType;
|
||||
subscriptionRoot?: LinkedType;
|
||||
}) => {
|
||||
const fetcher = createFetcher(options);
|
||||
const client: {
|
||||
query?: Function;
|
||||
mutation?: Function;
|
||||
} = {};
|
||||
|
||||
if (queryRoot) {
|
||||
client.query = (request: any) => {
|
||||
try {
|
||||
return fetcher(
|
||||
generateGraphqlOperation('query', queryRoot, request),
|
||||
);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
if (mutationRoot) {
|
||||
client.mutation = (request: any) => {
|
||||
try {
|
||||
return fetcher(
|
||||
generateGraphqlOperation('mutation', mutationRoot, request),
|
||||
);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return client as any;
|
||||
};
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
// @ts-nocheck
|
||||
// Generates GraphQL operation strings from a declarative request object.
|
||||
// Uses convention-based type inference for argument types so the core
|
||||
// client works without a genql-generated type map.
|
||||
import type {
|
||||
LinkedField,
|
||||
LinkedType,
|
||||
GraphqlOperation,
|
||||
} from './types';
|
||||
import { inferArgType } from './twenty-arg-type-inference';
|
||||
|
||||
type Request = boolean | number | Fields;
|
||||
|
||||
type Fields = {
|
||||
[field: string]: Request;
|
||||
};
|
||||
|
||||
type Variables = {
|
||||
[name: string]: {
|
||||
value: unknown;
|
||||
typing: [LinkedType, string];
|
||||
};
|
||||
};
|
||||
|
||||
type Context = {
|
||||
root: LinkedType;
|
||||
varCounter: number;
|
||||
variables: Variables;
|
||||
fragmentCounter: number;
|
||||
fragments: string[];
|
||||
};
|
||||
|
||||
const getFieldFromPath = (
|
||||
root: LinkedType | undefined,
|
||||
path: string[],
|
||||
): LinkedField | undefined => {
|
||||
let current: LinkedField | undefined;
|
||||
|
||||
if (!root) return undefined;
|
||||
if (path.length === 0) return undefined;
|
||||
|
||||
for (const fieldName of path) {
|
||||
const type = current ? current.type : root;
|
||||
|
||||
if (!type?.fields) return undefined;
|
||||
|
||||
const possibleTypes = Object.keys(type.fields)
|
||||
.filter((key) => key.startsWith('on_'))
|
||||
.reduce(
|
||||
(types, key) => {
|
||||
const field = type.fields && type.fields[key];
|
||||
if (field) types.push(field.type);
|
||||
return types;
|
||||
},
|
||||
[type],
|
||||
);
|
||||
|
||||
let field: LinkedField | null = null;
|
||||
|
||||
for (const possibleType of possibleTypes) {
|
||||
const found = possibleType.fields && possibleType.fields[fieldName];
|
||||
if (found) {
|
||||
field = found;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!field) return undefined;
|
||||
|
||||
current = field;
|
||||
}
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
const parseRequest = (
|
||||
request: Request | undefined,
|
||||
ctx: Context,
|
||||
path: string[],
|
||||
): string => {
|
||||
if (typeof request === 'object' && '__args' in request) {
|
||||
const args: any = request.__args;
|
||||
let fields: Request | undefined = { ...request };
|
||||
delete fields.__args;
|
||||
const argNames = Object.keys(args);
|
||||
|
||||
if (argNames.length === 0) {
|
||||
return parseRequest(fields, ctx, path);
|
||||
}
|
||||
|
||||
const field = getFieldFromPath(ctx.root, path);
|
||||
|
||||
const argStrings = argNames.map((argName) => {
|
||||
ctx.varCounter++;
|
||||
const varName = `v${ctx.varCounter}`;
|
||||
|
||||
const typing = field?.args && field.args[argName];
|
||||
|
||||
if (typing) {
|
||||
ctx.variables[varName] = {
|
||||
value: args[argName],
|
||||
typing,
|
||||
};
|
||||
} else {
|
||||
const operationName = path[0] || '';
|
||||
const inferredType = inferArgType(operationName, argName);
|
||||
|
||||
ctx.variables[varName] = {
|
||||
value: args[argName],
|
||||
typing: [{ name: inferredType }, inferredType],
|
||||
};
|
||||
}
|
||||
|
||||
return `${argName}:$${varName}`;
|
||||
});
|
||||
return `(${argStrings})${parseRequest(fields, ctx, path)}`;
|
||||
} else if (
|
||||
typeof request === 'object' &&
|
||||
Object.keys(request).length > 0
|
||||
) {
|
||||
const fields = request;
|
||||
const fieldNames = Object.keys(fields).filter((key) =>
|
||||
Boolean(fields[key]),
|
||||
);
|
||||
|
||||
if (fieldNames.length === 0) {
|
||||
throw new Error(
|
||||
`field selection should not be empty: ${path.join('.')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const field =
|
||||
path.length > 0 ? getFieldFromPath(ctx.root, path) : null;
|
||||
const type = field ? field.type : ctx.root;
|
||||
const scalarFields = type?.scalar;
|
||||
|
||||
let scalarFieldsFragment: string | undefined;
|
||||
|
||||
if (fieldNames.includes('__scalar')) {
|
||||
const falsyFieldNames = new Set(
|
||||
Object.keys(fields).filter((key) => !Boolean(fields[key])),
|
||||
);
|
||||
if (scalarFields?.length) {
|
||||
ctx.fragmentCounter++;
|
||||
scalarFieldsFragment = `f${ctx.fragmentCounter}`;
|
||||
|
||||
ctx.fragments.push(
|
||||
`fragment ${scalarFieldsFragment} on ${
|
||||
type.name
|
||||
}{${scalarFields
|
||||
.filter((scalarField) => !falsyFieldNames.has(scalarField))
|
||||
.join(',')}}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fieldsSelection = fieldNames
|
||||
.filter(
|
||||
(fieldName) => !['__scalar', '__name'].includes(fieldName),
|
||||
)
|
||||
.map((fieldName) => {
|
||||
const parsed = parseRequest(fields[fieldName], ctx, [
|
||||
...path,
|
||||
fieldName,
|
||||
]);
|
||||
|
||||
if (fieldName.startsWith('on_')) {
|
||||
ctx.fragmentCounter++;
|
||||
const implementationFragment = `f${ctx.fragmentCounter}`;
|
||||
|
||||
const typeMatch = fieldName.match(/^on_(.+)/);
|
||||
|
||||
if (!typeMatch || !typeMatch[1])
|
||||
throw new Error('match failed');
|
||||
|
||||
ctx.fragments.push(
|
||||
`fragment ${implementationFragment} on ${typeMatch[1]}${parsed}`,
|
||||
);
|
||||
|
||||
return `...${implementationFragment}`;
|
||||
} else {
|
||||
return `${fieldName}${parsed}`;
|
||||
}
|
||||
})
|
||||
.concat(
|
||||
scalarFieldsFragment ? [`...${scalarFieldsFragment}`] : [],
|
||||
)
|
||||
.join(',');
|
||||
|
||||
return `{${fieldsSelection}}`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const generateGraphqlOperation = (
|
||||
operation: 'query' | 'mutation' | 'subscription',
|
||||
root: LinkedType,
|
||||
fields?: Fields,
|
||||
): GraphqlOperation => {
|
||||
const ctx: Context = {
|
||||
root: root,
|
||||
varCounter: 0,
|
||||
variables: {},
|
||||
fragmentCounter: 0,
|
||||
fragments: [],
|
||||
};
|
||||
const result = parseRequest(fields, ctx, []);
|
||||
|
||||
const varNames = Object.keys(ctx.variables);
|
||||
|
||||
const varsString =
|
||||
varNames.length > 0
|
||||
? `(${varNames.map((varName) => {
|
||||
const variableType = ctx.variables[varName].typing[1];
|
||||
return `$${varName}:${variableType}`;
|
||||
})})`
|
||||
: '';
|
||||
|
||||
const operationName = fields?.__name || '';
|
||||
|
||||
return {
|
||||
query: [
|
||||
`${operation} ${operationName}${varsString}${result}`,
|
||||
...ctx.fragments,
|
||||
].join(','),
|
||||
variables: Object.keys(ctx.variables).reduce<{
|
||||
[name: string]: unknown;
|
||||
}>(
|
||||
(result, varName) => {
|
||||
result[varName] = ctx.variables[varName].value;
|
||||
return result;
|
||||
},
|
||||
{},
|
||||
),
|
||||
...(operationName
|
||||
? { operationName: operationName.toString() }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
// Lightweight replacement for GenqlError — no genql dependency.
|
||||
|
||||
type GraphqlErrorEntry = {
|
||||
message?: string;
|
||||
locations?: Array<{ line: number; column: number }>;
|
||||
path?: string[];
|
||||
extensions?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export class CoreGraphqlError extends Error {
|
||||
errors: GraphqlErrorEntry[];
|
||||
data?: unknown;
|
||||
|
||||
constructor(errors: GraphqlErrorEntry[], data?: unknown) {
|
||||
const message =
|
||||
errors.map((entry) => entry?.message || '').join('\n') ||
|
||||
'GraphQL error';
|
||||
|
||||
super(message);
|
||||
this.errors = errors;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// @ts-nocheck
|
||||
// Twenty core client internals — fully self-contained, no genql dependency.
|
||||
|
||||
export { generateGraphqlOperation } from './generate-graphql-operation';
|
||||
export { createClient } from './create-client';
|
||||
export { CoreGraphqlError } from './graphql-error';
|
||||
|
||||
export type { GraphqlOperation, ClientOptions } from './types';
|
||||
|
||||
// FieldsSelection is a no-op identity type in stub mode (no real type map).
|
||||
// When genql codegen runs during app:dev, the generated index.ts replaces
|
||||
// this entire file with real typed selections.
|
||||
export type FieldsSelection<_TSource, _TFields> = _TSource;
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
// Twenty-specific convention-based GraphQL argument type inference.
|
||||
// Derives GraphQL variable types from resolver naming patterns
|
||||
// (e.g. createCompany → CompanyCreateInput!) so the core client
|
||||
// works without a genql-generated type map.
|
||||
|
||||
type ResolverPattern = {
|
||||
regex: RegExp;
|
||||
getArgType: (objectName: string, argName: string) => string | null;
|
||||
};
|
||||
|
||||
const RESOLVER_PATTERNS: ResolverPattern[] = [
|
||||
{
|
||||
regex: /^create(.+)$/,
|
||||
getArgType: (objectName, argName) => {
|
||||
if (argName === 'data') return `${objectName}CreateInput!`;
|
||||
if (argName === 'upsert') return 'Boolean';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
regex: /^update(.+)$/,
|
||||
getArgType: (objectName, argName) => {
|
||||
if (argName === 'id') return 'UUID!';
|
||||
if (argName === 'data') return `${objectName}UpdateInput!`;
|
||||
if (argName === 'filter') return `${objectName}FilterInput!`;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
regex: /^delete(.+)$/,
|
||||
getArgType: (objectName, argName) => {
|
||||
if (argName === 'id') return 'UUID!';
|
||||
if (argName === 'filter') return `${objectName}FilterInput!`;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
regex: /^destroy(.+)$/,
|
||||
getArgType: (objectName, argName) => {
|
||||
if (argName === 'id') return 'UUID!';
|
||||
if (argName === 'filter') return `${objectName}FilterInput!`;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
regex: /^restore(.+)$/,
|
||||
getArgType: (objectName, argName) => {
|
||||
if (argName === 'id') return 'UUID!';
|
||||
if (argName === 'filter') return `${objectName}FilterInput!`;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
regex: /^merge(.+)$/,
|
||||
getArgType: (_objectName, argName) => {
|
||||
if (argName === 'ids') return '[UUID!]!';
|
||||
if (argName === 'conflictPriorityIndex') return 'Int!';
|
||||
if (argName === 'dryRun') return 'Boolean';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const QUERY_COMMON_ARG_TYPES: Record<string, string> = {
|
||||
first: 'Int',
|
||||
last: 'Int',
|
||||
offset: 'Int',
|
||||
offsetForRecords: 'Int',
|
||||
limit: 'Int',
|
||||
before: 'String',
|
||||
after: 'String',
|
||||
id: 'UUID!',
|
||||
ids: '[UUID]',
|
||||
upsert: 'Boolean',
|
||||
dryRun: 'Boolean',
|
||||
conflictPriorityIndex: 'Int!',
|
||||
viewId: 'UUID',
|
||||
};
|
||||
|
||||
const inferArgTypeForUnprefixedResolver = (
|
||||
resolverName: string,
|
||||
argName: string,
|
||||
): string | null => {
|
||||
const commonType = QUERY_COMMON_ARG_TYPES[argName];
|
||||
|
||||
if (commonType) return commonType;
|
||||
|
||||
let objectName = resolverName;
|
||||
|
||||
if (resolverName.endsWith('Duplicates')) {
|
||||
objectName = resolverName.slice(0, -'Duplicates'.length);
|
||||
} else if (resolverName.endsWith('GroupBy')) {
|
||||
objectName = resolverName.slice(0, -'GroupBy'.length);
|
||||
}
|
||||
|
||||
const pascalObjectName =
|
||||
objectName.charAt(0).toUpperCase() + objectName.slice(1);
|
||||
|
||||
if (argName === 'filter') return `${pascalObjectName}FilterInput`;
|
||||
if (argName === 'orderBy') return `[${pascalObjectName}OrderByInput]`;
|
||||
if (argName === 'orderByForRecords')
|
||||
return `[${pascalObjectName}OrderByInput]`;
|
||||
if (argName === 'groupBy') return `[${pascalObjectName}GroupByInput!]!`;
|
||||
if (argName === 'data') return `[${pascalObjectName}CreateInput!]`;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const inferArgType = (
|
||||
operationName: string,
|
||||
argName: string,
|
||||
): string => {
|
||||
for (const pattern of RESOLVER_PATTERNS) {
|
||||
const match = operationName.match(pattern.regex);
|
||||
|
||||
if (!match) continue;
|
||||
|
||||
const objectName = match[1]!;
|
||||
const resolved = pattern.getArgType(objectName, argName);
|
||||
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
const unprefixedResult = inferArgTypeForUnprefixedResolver(
|
||||
operationName,
|
||||
argName,
|
||||
);
|
||||
|
||||
if (unprefixedResult) return unprefixedResult;
|
||||
|
||||
throw new Error(
|
||||
`Cannot infer GraphQL type for argument '${argName}' ` +
|
||||
`on operation '${operationName}'. ` +
|
||||
`Run 'twenty app:build' to generate a typed client.`,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
// Minimal type definitions for the core client stub.
|
||||
// These mirror the shapes used by the genql runtime but are
|
||||
// self-contained — no imports from @genql/runtime.
|
||||
|
||||
export type LinkedArgMap = {
|
||||
[arg: string]: [LinkedType, string] | undefined;
|
||||
};
|
||||
|
||||
export type LinkedField = {
|
||||
type: LinkedType;
|
||||
args?: LinkedArgMap;
|
||||
};
|
||||
|
||||
export type LinkedFieldMap = {
|
||||
[field: string]: LinkedField | undefined;
|
||||
};
|
||||
|
||||
export type LinkedType = {
|
||||
name: string;
|
||||
fields?: LinkedFieldMap;
|
||||
scalar?: string[];
|
||||
};
|
||||
|
||||
export type GraphqlOperation = {
|
||||
query: string;
|
||||
variables?: { [name: string]: unknown };
|
||||
operationName?: string;
|
||||
};
|
||||
|
||||
export type ClientOptions = Omit<RequestInit, 'body' | 'headers'> & {
|
||||
url?: string;
|
||||
batch?: unknown;
|
||||
fetcher?: (
|
||||
operation: GraphqlOperation | GraphqlOperation[],
|
||||
) => Promise<unknown>;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
headers?: HeadersInit | (() => HeadersInit) | (() => Promise<HeadersInit>);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export { CoreApiClient } from './generated/core/index';
|
||||
export * as CoreSchema from './generated/core/schema';
|
||||
export { MetadataApiClient } from './generated/metadata/index';
|
||||
export * as MetadataSchema from './generated/metadata/schema';
|
||||
|
||||
Reference in New Issue
Block a user