introduce metadata api client to twenty sdk (#18233)
Logic function: hello-world.ts
```typescript
import { CoreApiClient } from 'twenty-sdk/generated/core';
import { MetadataApiClient } from 'twenty-sdk/generated/metadata';
const handler = async () => {
const coreClient = new CoreApiClient();
const metadataClient = new MetadataApiClient();
// Query the core /graphql endpoint — fetch some people
const coreResult = await coreClient.query({
people: {
edges: {
node: {
id: true,
name: {
firstName: true,
lastName: true,
},
},
},
},
});
// Query the metadata /metadata endpoint — fetch current workspace
const metadataResult = await metadataClient.query({
currentWorkspace: {
id: true,
displayName: true,
},
});
return {
coreResponse: coreResult,
metadataResponse: metadataResult,
};
};
```
With route trigger should now produce:
<img width="582" height="238" alt="Screenshot 2026-02-25 at 17 14 29"
src="https://github.com/user-attachments/assets/8c597113-7552-4d32-845a-352083d84ac7"
/>
```json
{
"coreResponse": {
"people": {
"edges": [
{
"node": {
"id": "20202020-b000-4485-94de-70c2a98daef2",
"name": {
"firstName": "Jeffery",
"lastName": "Griffin"
}
}
},
{
"node": {
"id": "20202020-b003-415a-9051-133248495f7f",
"name": {
"firstName": "Terry",
"lastName": "Melendez"
}
}
},
{
"node": {
"id": "20202020-b00e-4bc1-87c8-00aeb49c10f8",
"name": {
"firstName": "Lee",
"lastName": "Jones"
}
}
},
{
"node": {
"id": "20202020-b012-44c1-9fdc-90f110962d07",
"name": {
"firstName": "Sarah",
"lastName": "Hernandez"
}
}
},
]
}
},
...
"metadataResponse": {
"currentWorkspace": {
"id": "20202020-1c25-4d02-bf25-6aeccf7ea419",
"displayName": "Apple"
}
}
}
This commit is contained in:
@@ -41,7 +41,7 @@ yarn twenty auth:login
|
||||
yarn twenty entity:add
|
||||
|
||||
# Start dev mode: watches, builds, and syncs local changes to your workspace
|
||||
# (also auto-generates a typed API client in node_modules/twenty-sdk/generated)
|
||||
# (also auto-generates typed API clients — CoreApiClient and MetadataApiClient — in node_modules/twenty-sdk/generated)
|
||||
yarn twenty app:dev
|
||||
|
||||
# Watch your application's function logs
|
||||
@@ -110,7 +110,7 @@ In interactive mode, you can pick from:
|
||||
- Use `yarn twenty auth:login` to authenticate with your Twenty workspace.
|
||||
- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
|
||||
- Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
|
||||
- Types are auto‑generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`.
|
||||
- Two typed API clients are auto‑generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
|
||||
|
||||
|
||||
## Publish your application
|
||||
|
||||
@@ -163,7 +163,7 @@ export default defineObject({
|
||||
|
||||
Later commands will add more files and folders:
|
||||
|
||||
- `yarn twenty app:dev` will auto-generate a typed API client in `node_modules/twenty-sdk/generated` (typed Twenty client + workspace types).
|
||||
- `yarn twenty app:dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
|
||||
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
|
||||
|
||||
## Authentication
|
||||
@@ -423,10 +423,10 @@ Each function file uses `defineLogicFunction()` to export a configuration with a
|
||||
// src/app/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
|
||||
import Twenty, { type Person } from '~/generated';
|
||||
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new Twenty(); // generated typed client
|
||||
const client = new CoreApiClient();
|
||||
const name = 'name' in params.queryStringParameters
|
||||
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
: 'Hello world';
|
||||
@@ -635,10 +635,10 @@ To mark a logic function as a tool, set `isTool: true` and provide a `toolInputS
|
||||
```typescript
|
||||
// src/logic-functions/enrich-company.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import Twenty from '~/generated';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const result = await client.mutation({
|
||||
createTask: {
|
||||
@@ -761,18 +761,24 @@ You can create new skills in two ways:
|
||||
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill.
|
||||
- **Manual**: Create a new file and use `defineSkill()`, following the same pattern.
|
||||
|
||||
### Generated typed client
|
||||
### Generated typed clients
|
||||
|
||||
The typed client is auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema. Use it in your functions:
|
||||
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
|
||||
|
||||
- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data
|
||||
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
const { me } = await client.query({ me: { id: true, displayName: true } });
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
|
||||
```
|
||||
|
||||
The client is re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
|
||||
Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
|
||||
|
||||
#### Runtime credentials in logic functions
|
||||
|
||||
@@ -788,17 +794,17 @@ Notes:
|
||||
|
||||
#### Uploading files
|
||||
|
||||
The generated `Twenty` client includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
|
||||
The generated `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const client = new Twenty();
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
const fileBuffer = fs.readFileSync('./invoice.pdf');
|
||||
|
||||
const uploadedFile = await client.uploadFile(
|
||||
const uploadedFile = await metadataClient.uploadFile(
|
||||
fileBuffer, // file contents as a Buffer
|
||||
'invoice.pdf', // filename
|
||||
'application/pdf', // MIME type (defaults to 'application/octet-stream')
|
||||
@@ -828,7 +834,7 @@ uploadFile(
|
||||
| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object |
|
||||
|
||||
Key points:
|
||||
- The method sends the file to the **metadata endpoint** (not the main GraphQL endpoint), where the upload mutation is resolved.
|
||||
- The `uploadFile` method is available on `MetadataApiClient` because the upload mutation is resolved by the `/metadata` endpoint.
|
||||
- It uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed — consistent with how apps reference fields everywhere else.
|
||||
- The returned `url` is a signed URL you can use to access the uploaded file.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
A CLI and SDK to develop, build, and publish applications that extend [Twenty CRM](https://twenty.com).
|
||||
|
||||
- Type‑safe client and workspace entity typings
|
||||
- Two auto‑generated typed GraphQL clients: `CoreApiClient` (workspace data) and `MetadataApiClient` (workspace configuration & file uploads)
|
||||
- Built‑in CLI for auth, dev mode (watch & sync), uninstall, and function management
|
||||
- Works great with the scaffolder: [create-twenty-app](https://www.npmjs.com/package/create-twenty-app)
|
||||
|
||||
|
||||
@@ -44,6 +44,16 @@
|
||||
"types": "./generated/index.ts",
|
||||
"import": "./generated/index.ts",
|
||||
"require": "./generated/index.ts"
|
||||
},
|
||||
"./generated/core": {
|
||||
"types": "./generated/core/index.ts",
|
||||
"import": "./generated/core/index.ts",
|
||||
"require": "./generated/core/index.ts"
|
||||
},
|
||||
"./generated/metadata": {
|
||||
"types": "./generated/metadata/index.ts",
|
||||
"import": "./generated/metadata/index.ts",
|
||||
"require": "./generated/metadata/index.ts"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0",
|
||||
@@ -118,6 +128,12 @@
|
||||
],
|
||||
"generated": [
|
||||
"generated/index.ts"
|
||||
],
|
||||
"generated/core": [
|
||||
"generated/core/index.ts"
|
||||
],
|
||||
"generated/metadata": [
|
||||
"generated/metadata/index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,6 +434,19 @@ export class ApiService {
|
||||
async getSchema(options?: {
|
||||
authToken?: string;
|
||||
}): Promise<ApiResponse<string>> {
|
||||
return this.introspectEndpoint('/graphql', options);
|
||||
}
|
||||
|
||||
async getMetadataSchema(options?: {
|
||||
authToken?: string;
|
||||
}): Promise<ApiResponse<string>> {
|
||||
return this.introspectEndpoint('/metadata', options);
|
||||
}
|
||||
|
||||
private async introspectEndpoint(
|
||||
endpoint: string,
|
||||
options?: { authToken?: string },
|
||||
): Promise<ApiResponse<string>> {
|
||||
try {
|
||||
const introspectionQuery = getIntrospectionQuery();
|
||||
|
||||
@@ -447,7 +460,7 @@ export class ApiService {
|
||||
}
|
||||
|
||||
const response = await this.client.post(
|
||||
'/graphql',
|
||||
endpoint,
|
||||
{
|
||||
query: introspectionQuery,
|
||||
},
|
||||
@@ -466,7 +479,7 @@ export class ApiService {
|
||||
return {
|
||||
success: true,
|
||||
data: printSchema(schema),
|
||||
message: 'Successfully load schema',
|
||||
message: `Successfully loaded schema from ${endpoint}`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
@@ -474,7 +487,7 @@ export class ApiService {
|
||||
success: false,
|
||||
error:
|
||||
error.response.data.errors[0]?.message ||
|
||||
'Failed to load graphql Schema',
|
||||
`Failed to load schema from ${endpoint}`,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
|
||||
+14
-6
@@ -24,7 +24,6 @@ import { ClientService } from '@/cli/utilities/client/client-service';
|
||||
|
||||
type TwentyClassType = new (options?: {
|
||||
url?: string;
|
||||
metadataUrl?: string;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
}) => {
|
||||
query: (request: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -49,7 +48,6 @@ export type GraphqlOperation = Record<string, unknown>
|
||||
|
||||
export type ClientOptions = {
|
||||
url?: string
|
||||
metadataUrl?: string
|
||||
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>)
|
||||
fetcher?: (operation: GraphqlOperation | GraphqlOperation[]) => Promise<unknown>
|
||||
fetch?: typeof globalThis.fetch
|
||||
@@ -128,9 +126,20 @@ describe('ClientService generated Twenty auth behavior', () => {
|
||||
const clientService = new ClientService();
|
||||
await (
|
||||
clientService as unknown as {
|
||||
injectTwentyClient: (output: string) => Promise<void>;
|
||||
injectClientWrapper: (
|
||||
output: string,
|
||||
options: {
|
||||
className: string;
|
||||
defaultUrl: string;
|
||||
includeUploadFile: boolean;
|
||||
},
|
||||
) => Promise<void>;
|
||||
}
|
||||
).injectTwentyClient(temporaryGeneratedClientDirectory);
|
||||
).injectClientWrapper(temporaryGeneratedClientDirectory, {
|
||||
className: 'MetadataApiClient',
|
||||
defaultUrl: '`${process.env.TWENTY_API_URL}/metadata`',
|
||||
includeUploadFile: true,
|
||||
});
|
||||
|
||||
const generatedIndexContent = await readFile(
|
||||
temporaryGeneratedIndexTsPath,
|
||||
@@ -153,7 +162,7 @@ describe('ClientService generated Twenty auth behavior', () => {
|
||||
`${pathToFileURL(temporaryGeneratedIndexMjsPath).href}?t=${Date.now()}`
|
||||
);
|
||||
|
||||
TwentyClass = generatedModule.default as TwentyClassType;
|
||||
TwentyClass = generatedModule.MetadataApiClient as TwentyClassType;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -379,7 +388,6 @@ describe('ClientService generated Twenty auth behavior', () => {
|
||||
|
||||
const twentyClient = new TwentyClass({
|
||||
url: 'https://example.com/graphql',
|
||||
metadataUrl: 'https://example.com/metadata',
|
||||
fetch: fetchMock as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,24 @@ import { generate } from '@genql/cli';
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
DEFAULT_APP_ACCESS_TOKEN_NAME,
|
||||
DEFAULT_API_KEY_NAME,
|
||||
DEFAULT_API_URL_NAME,
|
||||
DEFAULT_APP_ACCESS_TOKEN_NAME,
|
||||
GENERATED_DIR,
|
||||
} from 'twenty-shared/application';
|
||||
|
||||
type ClientWrapperOptions = {
|
||||
className: string;
|
||||
defaultUrl: string;
|
||||
includeUploadFile: boolean;
|
||||
};
|
||||
|
||||
const COMMON_SCALAR_TYPES = {
|
||||
DateTime: 'string',
|
||||
JSON: 'Record<string, unknown>',
|
||||
UUID: 'string',
|
||||
};
|
||||
|
||||
export class ClientService {
|
||||
private apiService: ApiService;
|
||||
|
||||
@@ -26,30 +38,55 @@ export class ClientService {
|
||||
const outputPath = this.resolveGeneratedPath(appPath);
|
||||
const tempPath = `${outputPath}.tmp`;
|
||||
|
||||
const getSchemaResponse = await this.apiService.getSchema({ authToken });
|
||||
const [coreSchemaResponse, metadataSchemaResponse] = await Promise.all([
|
||||
this.apiService.getSchema({ authToken }),
|
||||
this.apiService.getMetadataSchema({ authToken }),
|
||||
]);
|
||||
|
||||
if (!getSchemaResponse.success) {
|
||||
if (!coreSchemaResponse.success) {
|
||||
throw new Error(
|
||||
`Failed to introspect schema: ${JSON.stringify(getSchemaResponse.error)}`,
|
||||
`Failed to introspect core schema: ${JSON.stringify(coreSchemaResponse.error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { data: schema } = getSchemaResponse;
|
||||
if (!metadataSchemaResponse.success) {
|
||||
throw new Error(
|
||||
`Failed to introspect metadata schema: ${JSON.stringify(metadataSchemaResponse.error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await fs.ensureDir(tempPath);
|
||||
await fs.emptyDir(tempPath);
|
||||
|
||||
await generate({
|
||||
schema,
|
||||
output: tempPath,
|
||||
scalarTypes: {
|
||||
DateTime: 'string',
|
||||
JSON: 'Record<string, unknown>',
|
||||
UUID: 'string',
|
||||
},
|
||||
await Promise.all([
|
||||
generate({
|
||||
schema: coreSchemaResponse.data,
|
||||
output: join(tempPath, 'core'),
|
||||
scalarTypes: COMMON_SCALAR_TYPES,
|
||||
}),
|
||||
generate({
|
||||
schema: metadataSchemaResponse.data,
|
||||
output: join(tempPath, 'metadata'),
|
||||
scalarTypes: {
|
||||
...COMMON_SCALAR_TYPES,
|
||||
Upload: 'File',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.injectClientWrapper(join(tempPath, 'core'), {
|
||||
className: 'CoreApiClient',
|
||||
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\``,
|
||||
includeUploadFile: false,
|
||||
});
|
||||
|
||||
await this.injectTwentyClient(tempPath);
|
||||
await this.injectClientWrapper(join(tempPath, 'metadata'), {
|
||||
className: 'MetadataApiClient',
|
||||
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\``,
|
||||
includeUploadFile: true,
|
||||
});
|
||||
|
||||
await this.writeBarrelIndex(tempPath);
|
||||
|
||||
await fs.remove(outputPath);
|
||||
await fs.move(tempPath, outputPath);
|
||||
@@ -59,19 +96,89 @@ export class ClientService {
|
||||
return join(appPath, 'node_modules', 'twenty-sdk', GENERATED_DIR);
|
||||
}
|
||||
|
||||
private async injectTwentyClient(output: string) {
|
||||
const twentyClientContent = `
|
||||
private async writeBarrelIndex(outputDir: string): Promise<void> {
|
||||
const barrelContent = `export { CoreApiClient } from './core/index';
|
||||
export { MetadataApiClient } from './metadata/index';
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(outputDir, 'index.ts'), barrelContent);
|
||||
}
|
||||
|
||||
private async injectClientWrapper(
|
||||
output: string,
|
||||
options: ClientWrapperOptions,
|
||||
): Promise<void> {
|
||||
const clientContent = this.buildClientWrapperTemplate(options);
|
||||
|
||||
await fs.appendFile(join(output, 'index.ts'), clientContent);
|
||||
}
|
||||
|
||||
private buildClientWrapperTemplate(options: ClientWrapperOptions): string {
|
||||
const { className, defaultUrl, includeUploadFile } = options;
|
||||
|
||||
const uploadFileMethod = includeUploadFile
|
||||
? `
|
||||
async uploadFile(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType: string = 'application/octet-stream',
|
||||
fieldMetadataUniversalIdentifier: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
url: string;
|
||||
}> {
|
||||
const form = new FormData();
|
||||
|
||||
form.append(
|
||||
'operations',
|
||||
JSON.stringify({
|
||||
query: \`mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) {
|
||||
uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url }
|
||||
}\`,
|
||||
variables: { file: null, fieldMetadataUniversalIdentifier },
|
||||
}),
|
||||
);
|
||||
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
form.append('0', new Blob([fileBuffer], { type: contentType }), filename);
|
||||
|
||||
const result = await this.executeGraphqlRequestWithOptionalRefresh({
|
||||
operation: form,
|
||||
headers: {},
|
||||
requestInit: {
|
||||
method: 'POST',
|
||||
},
|
||||
});
|
||||
|
||||
if (result.errors) {
|
||||
throw new GenqlError(result.errors, result.data);
|
||||
}
|
||||
|
||||
const data = result.data as Record<string, unknown>;
|
||||
|
||||
return data.uploadFilesFieldFileByUniversalIdentifier as {
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
url: string;
|
||||
}
|
||||
}
|
||||
`
|
||||
: '';
|
||||
|
||||
return `
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Custom Twenty client (auto-injected)
|
||||
// ${className} (auto-injected)
|
||||
// ----------------------------------------------------
|
||||
|
||||
const APP_ACCESS_TOKEN_ENV_KEY = '${DEFAULT_APP_ACCESS_TOKEN_NAME}';
|
||||
const API_KEY_ENV_KEY = '${DEFAULT_API_KEY_NAME}';
|
||||
|
||||
type TwentyClientOptions = ClientOptions & {
|
||||
metadataUrl?: string;
|
||||
}
|
||||
type ${className}Options = ClientOptions
|
||||
|
||||
type ProcessEnvironment = Record<string, string | undefined>
|
||||
|
||||
@@ -157,39 +264,35 @@ const hasAuthenticationErrorInGraphqlPayload = (
|
||||
return payload.errors.some((error) => {
|
||||
return (
|
||||
error.extensions?.code === 'UNAUTHENTICATED' ||
|
||||
// Fallback for payloads that don't provide structured error codes.
|
||||
error.message?.toLowerCase() === 'unauthorized'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const defaultOptions: TwentyClientOptions = {
|
||||
url: \`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\`,
|
||||
metadataUrl: \`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\`,
|
||||
const defaultOptions: ${className}Options = {
|
||||
url: ${defaultUrl},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
|
||||
export default class Twenty {
|
||||
export class ${className} {
|
||||
private client: Client;
|
||||
private url: string;
|
||||
private metadataUrl: 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?: TwentyClientOptions) {
|
||||
const merged: TwentyClientOptions = {
|
||||
constructor(options?: ${className}Options) {
|
||||
const merged: ${className}Options = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
}
|
||||
|
||||
const {
|
||||
url,
|
||||
metadataUrl,
|
||||
headers,
|
||||
fetch: customFetchImplementation,
|
||||
fetcher: _fetcher,
|
||||
@@ -198,7 +301,6 @@ export default class Twenty {
|
||||
} = merged;
|
||||
|
||||
this.url = url ?? '';
|
||||
this.metadataUrl = metadataUrl ?? this.url.replace(/\\/graphql$/, '/metadata');
|
||||
this.requestOptions = requestOptions;
|
||||
this.headers = headers ?? {};
|
||||
this.fetchImplementation = customFetchImplementation ?? globalThis.fetch ?? null;
|
||||
@@ -232,71 +334,18 @@ export default class Twenty {
|
||||
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], { type: contentType }), filename);
|
||||
|
||||
const result = await this.executeGraphqlRequestWithOptionalRefresh({
|
||||
operation: form,
|
||||
url: this.metadataUrl,
|
||||
headers: {},
|
||||
requestInit: {
|
||||
method: 'POST',
|
||||
},
|
||||
});
|
||||
|
||||
if (result.errors) {
|
||||
throw new GenqlError(result.errors, result.data);
|
||||
}
|
||||
|
||||
const data = result.data as Record<string, unknown>;
|
||||
|
||||
return data.uploadFilesFieldFileByUniversalIdentifier as {
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
url: string;
|
||||
}
|
||||
}
|
||||
|
||||
${uploadFileMethod}
|
||||
private async executeGraphqlRequestWithOptionalRefresh({
|
||||
operation,
|
||||
url = this.url,
|
||||
headers,
|
||||
requestInit,
|
||||
}: {
|
||||
operation: GraphqlOperation | GraphqlOperation[] | FormData;
|
||||
url?: string;
|
||||
headers?: HeadersInit;
|
||||
requestInit?: RequestInit;
|
||||
}) {
|
||||
const firstResponse = await this.executeGraphqlRequest({
|
||||
operation,
|
||||
url,
|
||||
headers,
|
||||
requestInit,
|
||||
token: this.authorizationToken,
|
||||
@@ -308,7 +357,6 @@ export default class Twenty {
|
||||
if (refreshedAccessToken) {
|
||||
const retryResponse = await this.executeGraphqlRequest({
|
||||
operation,
|
||||
url,
|
||||
headers,
|
||||
requestInit,
|
||||
token: refreshedAccessToken,
|
||||
@@ -323,13 +371,11 @@ export default class Twenty {
|
||||
|
||||
private async executeGraphqlRequest({
|
||||
operation,
|
||||
url,
|
||||
headers,
|
||||
requestInit,
|
||||
token,
|
||||
}: {
|
||||
operation: GraphqlOperation | GraphqlOperation[] | FormData;
|
||||
url: string;
|
||||
headers?: HeadersInit;
|
||||
requestInit?: RequestInit;
|
||||
token: string | null;
|
||||
@@ -359,7 +405,7 @@ export default class Twenty {
|
||||
requestHeaders.delete('Authorization');
|
||||
}
|
||||
|
||||
const response = await this.fetchImplementation.call(globalThis, url, {
|
||||
const response = await this.fetchImplementation.call(globalThis, this.url, {
|
||||
...this.requestOptions,
|
||||
...requestInit,
|
||||
method: requestInit?.method ?? 'POST',
|
||||
@@ -465,7 +511,5 @@ export default class Twenty {
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
await fs.appendFile(join(output, 'index.ts'), twentyClientContent);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -13,7 +13,12 @@ import {
|
||||
} from 'twenty-shared/application';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
const API_CLIENT_FILES = ['types.ts', 'schema.ts'];
|
||||
const API_CLIENT_FILES = [
|
||||
'core/types.ts',
|
||||
'core/schema.ts',
|
||||
'metadata/types.ts',
|
||||
'metadata/schema.ts',
|
||||
];
|
||||
|
||||
export type UploadFilesOrchestratorStepOutput = {
|
||||
fileUploader: FileUploader | null;
|
||||
|
||||
Reference in New Issue
Block a user