Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/api/CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## [14.0.0]

### ⚠️ Breaking Changes

- **Minimal Node version increased**: Package now requires Node >= 18.0.0

### Added
- **File upload functionality**: Added the ability to send files via Monday SDK.

## [13.0.0]

### ⚠️ Breaking Changes
Expand Down
35 changes: 35 additions & 0 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,41 @@ const { boards } = await client.request<{
}>(`query { boards(ids: some_id) { name } }`, undefined, { timeout: 20_000 });
```

### File uploads

The SDK supports file uploads. Simply pass a `File` or `Blob` object in your variables, and the client will automatically handle the multipart request.

> **Important:** The SDK uses Node's `fetch` method that recognizes built-in `File` and `Blob` to create multi-part request.
Comment thread
damian-rakus marked this conversation as resolved.

> **Note:** You **must** use the endpoint `https://api.monday.com/v2/file` when uploading files. Set this endpoint via the `endpoint` option in the `ApiClient` constructor as shown below.
Comment thread
damian-rakus marked this conversation as resolved.
Outdated

```typescript
import { ApiClient } from '@mondaydotcomorg/api';

const client = new ApiClient({ endpoint: 'https://api.monday.com/v2/file', token: '<API-TOKEN>' });

// Create a file to upload
const file = new File([Buffer.from('Hello World!')], 'test.txt', { type: 'text/plain' });
Comment thread
damian-rakus marked this conversation as resolved.

// Upload the file to a column
const result = await client.request(
`mutation ($file: File!, $itemId: ID!, $columnId: String!) {
add_file_to_column(file: $file, item_id: $itemId, column_id: $columnId) {
id
name
url
}
}`,
{
file,
itemId: 'your_item_id',
columnId: 'files', // your file column id
}
);

console.log(result.add_file_to_column);
```

### Using the types

The package exports all the types used by the SDK, so you can use them in your code.
Expand Down
161 changes: 158 additions & 3 deletions packages/api/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { ApiVersionType, DEFAULT_VERSION, QueryVariables } from './constants/ind
import { Sdk, getSdk } from './generated/sdk';
import pkg from '../package.json';
import { getApiEndpoint } from './shared/get-api-endpoint';
import { GraphQLClientResponse, RequestConfig } from 'graphql-request/build/esm/types';
import { GraphQLClientResponse, RequestConfig, RequestMiddleware, Variables } from 'graphql-request/build/esm/types';
import z from 'zod';

export { ClientError };

interface FileEntry {
path: string;
file: File | Blob;
}
export interface ApiClientConfig {
token: string;
apiVersion?: string;
Expand Down Expand Up @@ -98,6 +101,8 @@ export class ApiClient {
return new GraphQLClient(endpoint, {
...this.requestConfig,
headers: mergedHeaders,
requestMiddleware: this.createFileUploadMiddleware(this.requestConfig?.requestMiddleware),
fetch: this.requestConfig?.fetch ?? fetch
});
}

Expand Down Expand Up @@ -198,4 +203,154 @@ export class ApiClient {
timeoutId,
};
}
}

/**
* Checks if a value is a File or Blob
*/
private isFile(value: unknown): value is File | Blob {
return (typeof File !== 'undefined' && value instanceof File) || (typeof Blob !== 'undefined' && value instanceof Blob);
}

/**
* Recursively extracts files from variables and returns the cleaned variables
* along with file mappings for the multipart request spec.
*
* @param variables - The original variables object
* @param path - Current path in the object (for building variable paths)
* @returns Object with cleaned variables (files replaced with null) and file mappings
*/
private extractFiles(
variables: Variables,
path = 'variables'
): { cleanedVariables: Variables; files: FileEntry[] } {
const files: FileEntry[] = [];

const processValue = (value: unknown, currentPath: string): unknown => {
if (this.isFile(value)) {
files.push({ path: currentPath, file: value });
return null; // Replace file with null per the spec
Comment thread
damian-rakus marked this conversation as resolved.
Outdated
}

if (Array.isArray(value)) {
return value.map((item, index) => processValue(item, `${currentPath}.${index}`));
}

if (value !== null && typeof value === 'object') {
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
result[key] = processValue(val, `${currentPath}.${key}`);
}
return result;
}

return value;
};

const cleanedVariables = processValue(variables, path) as Variables;
return { cleanedVariables, files };
}

/**
* Creates a FormData object for GraphQL multipart file upload requests
* following the GraphQL multipart request specification.
*/
private createMultipartFormData(query: string, variables: Variables, files: FileEntry[]): FormData {
const formData = new FormData();

// Add query and variables as separate fields
formData.append('query', query);
formData.append('variables', JSON.stringify(variables));

// Build the map: { "0": ["variables.file"], "1": ["variables.files.0"] }
const map: Record<string, string[]> = {};
files.forEach((entry, index) => {
map[String(index)] = [entry.path];
});
formData.append('map', JSON.stringify(map));

// Add files with their index as the key
files.forEach((entry, index) => {
formData.append(String(index), entry.file);
});

return formData;
}

/**
* Checks if variables contain any File or Blob objects
*/
private hasFiles(variables: Variables | undefined): boolean {
if (!variables) {
return false;
}

const check = (value: unknown): boolean => {
if (this.isFile(value)) {
return true;
}
if (Array.isArray(value)) {
return value.some(check);
}
if (value !== null && typeof value === 'object') {
return Object.values(value).some(check);
}
return false;
};

return check(variables);
}

/**
* Creates a request middleware that automatically handles file uploads
* by converting requests to multipart/form-data when files are detected in variables.
*
* This middleware intercepts the request before it's sent, checks for File/Blob objects
* in the variables, and if found, converts the request body to FormData following
* the GraphQL multipart request specification.
*
* @param existingMiddleware - Optional existing middleware to chain with
* @returns A RequestMiddleware function that handles file uploads
*/
private createFileUploadMiddleware(existingMiddleware?: RequestMiddleware): RequestMiddleware {
return async (request) => {
// First, apply any existing middleware
const processedRequest = existingMiddleware ? await existingMiddleware(request) : request;

const { variables, body } = processedRequest;

// Check if variables contain files (using original variables, not the stringified body)
if (variables && this.hasFiles(variables)) {
// Parse the body to get the query string
let query: string;
if (typeof body === 'string') {
try {
const parsed = JSON.parse(body);
query = parsed.query;
} catch {
// If we can't parse the body, return the request as-is
return processedRequest;
}
} else {
// Body is not a string (shouldn't happen in normal GraphQL flow)
return processedRequest;
}

// Extract files and create FormData
const { cleanedVariables, files } = this.extractFiles(variables);
const formData = this.createMultipartFormData(query, cleanedVariables, files);

// Remove Content-Type header to let the browser set it with the correct boundary
const headers = { ...(processedRequest.headers as Record<string, string>) };
delete headers['Content-Type'];
delete headers['content-type'];

return {
...processedRequest,
body: formData,
headers,
};
}
return processedRequest;
};
}
}
6 changes: 3 additions & 3 deletions packages/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mondaydotcomorg/api",
"version": "13.0.0",
"version": "14.0.0",
"description": "monday.com API client",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
Expand All @@ -18,7 +18,7 @@
"update-versions": "yarn build && node dist/scripts/update-versions.js"
},
"engines": {
"node": ">= 16.20.0"
"node": ">= 18.0.0"
},
"author": "monday.com Api Team",
"license": "MIT",
Expand Down Expand Up @@ -47,7 +47,7 @@
"@types/node": "^20.11.18",
"jest": "^29.7.0",
"moment": "^2.30.1",
"nock": "^13.5.0",
"undici": "^6.21.0",
"rollup": "^2.79.1",
"rollup-plugin-delete": "^2.0.0",
"rollup-plugin-dts": "^4.2.3",
Expand Down
Loading