Skip to content

Commit 38e6c71

Browse files
authored
Merge pull request #87 from mondaycom/feature/damianra/support-file-uploads
Add file upload functionality to Monday.com SDK
2 parents 689514e + ad4caec commit 38e6c71

File tree

12 files changed

+559
-99
lines changed

12 files changed

+559
-99
lines changed

packages/api/CHANGELOG.MD

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## [14.0.0]
4+
5+
### ⚠️ Breaking Changes
6+
7+
- **Minimal Node version increased**: Package now requires Node >= 18.0.0
8+
9+
### Added
10+
- **File upload functionality**: Added the ability to send files via Monday SDK.
11+
312
## [13.0.0]
413

514
### ⚠️ Breaking Changes

packages/api/README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,40 @@ const { boards } = await client.request<{
6666
// You can also define timeout for requests
6767
const { boards } = await client.request<{
6868
boards: [Board];
69-
}>(`query { boards(ids: some_id) { name } }`, undefined, { timeout: 20_000 });
69+
}>(`query { boards(ids: some_id) { name } }`, undefined, { timeoutMs: 20_000 });
70+
```
71+
72+
### File uploads
73+
74+
The SDK supports file uploads. Yo must pass Node's `File` or `Blob` object in your variables, and the client will automatically handle the multipart request.
75+
76+
> **Important:** The SDK uses Node's `fetch` method that recognizes built-in `File` and `Blob` to create multi-part request.
77+
78+
```typescript
79+
import { ApiClient } from '@mondaydotcomorg/api';
80+
81+
const client = new ApiClient({ token: '<API-TOKEN>' });
82+
83+
// Create a file to upload
84+
const file = new File([Buffer.from('Hello World!')], 'test.txt', { type: 'text/plain' });
85+
86+
// Upload the file to a column
87+
const result = await client.request(
88+
`mutation ($file: File!, $itemId: ID!, $columnId: String!) {
89+
add_file_to_column(file: $file, item_id: $itemId, column_id: $columnId) {
90+
id
91+
name
92+
url
93+
}
94+
}`,
95+
{
96+
file,
97+
itemId: 'your_item_id',
98+
columnId: 'files', // your file column id
99+
}
100+
);
101+
102+
console.log(result.add_file_to_column);
70103
```
71104

72105
### Using the types
Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { GraphQLClient, ClientError } from 'graphql-request';
2-
import { ApiVersionType, DEFAULT_VERSION, QueryVariables } from './constants/index';
3-
import { Sdk, getSdk } from './generated/sdk';
4-
import pkg from '../package.json';
5-
import { getApiEndpoint } from './shared/get-api-endpoint';
2+
import { ApiVersionType, DEFAULT_VERSION, QueryVariables } from '../constants/index';
3+
import { Sdk, getSdk } from '../generated/sdk';
4+
import pkg from '../../package.json';
5+
import { getApiEndpoint } from '../shared/get-api-endpoint';
66
import { GraphQLClientResponse, RequestConfig } from 'graphql-request/build/esm/types';
77
import z from 'zod';
8+
import { createFileUploadMiddleware } from './middleware/file-upload';
89

910
export { ClientError };
1011

@@ -19,7 +20,7 @@ const requestOptionsSchema = z.object({
1920
versionOverride: z.string().nonempty().optional().refine((version) => !version || isValidApiVersion(version), {
2021
message: "Invalid API version format. Expected format is 'yyyy-mm' with month as one of '01', '04', '07', or '10'.",
2122
}),
22-
timeout: z.number().positive().max(60_000).optional(),
23+
timeoutMs: z.number().positive().max(60_000).optional(),
2324
})
2425

2526
export type RequestOptions = z.infer<typeof requestOptionsSchema>;
@@ -98,6 +99,8 @@ export class ApiClient {
9899
return new GraphQLClient(endpoint, {
99100
...this.requestConfig,
100101
headers: mergedHeaders,
102+
requestMiddleware: createFileUploadMiddleware(this.requestConfig?.requestMiddleware),
103+
fetch: this.requestConfig?.fetch ?? fetch
101104
});
102105
}
103106

@@ -115,7 +118,7 @@ export class ApiClient {
115118
): Promise<T> => {
116119
const validatedOptions = options ? requestOptionsSchema.parse(options) : options;
117120
const client = this.createClient(validatedOptions);
118-
const { abortController, timeoutId } = this.createAbortController(validatedOptions?.timeout);
121+
const { abortController, timeoutId } = this.createAbortController(validatedOptions?.timeoutMs);
119122

120123
try {
121124
return await executor(client, abortController?.signal);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './api-client';
2+
export * from './seamless-api-client';
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { RequestMiddleware, Variables } from 'graphql-request/build/esm/types';
2+
3+
/**
4+
* Creates a request middleware that automatically handles file uploads
5+
* by converting requests to multipart/form-data when files are detected in variables.
6+
*
7+
* This middleware intercepts the request before it's sent, checks for File/Blob objects
8+
* in the variables, and if found, converts the request body to FormData following
9+
* the GraphQL multipart request specification.
10+
*
11+
* @param existingMiddleware - Optional existing middleware to chain with
12+
* @returns A RequestMiddleware function that handles file uploads
13+
*/
14+
export const createFileUploadMiddleware = (existingMiddleware?: RequestMiddleware): RequestMiddleware => {
15+
return async (request) => {
16+
const processedRequest = existingMiddleware ? await existingMiddleware(request) : request;
17+
18+
const { variables, body } = processedRequest;
19+
20+
if (!variables || !hasFiles(variables)) {
21+
return processedRequest;
22+
}
23+
24+
if(typeof body !== 'string') {
25+
return processedRequest;
26+
}
27+
28+
let query: string;
29+
try {
30+
const parsed = JSON.parse(body);
31+
query = parsed.query;
32+
} catch {
33+
// If we can't parse the body, return the request as-is
34+
return processedRequest;
35+
}
36+
37+
const { cleanedVariables, files } = extractFiles(variables);
38+
const formData = createMultipartFormData(query, cleanedVariables, files);
39+
40+
const headers = { ...(processedRequest.headers as Record<string, string>) };
41+
for (const key of Object.keys(headers)) {
42+
if (key.toLowerCase() === 'content-type') {
43+
delete headers[key];
44+
}
45+
}
46+
47+
return {
48+
...processedRequest,
49+
body: formData,
50+
headers,
51+
};
52+
};
53+
};
54+
55+
interface FileEntry {
56+
path: string;
57+
file: File | Blob;
58+
}
59+
60+
/**
61+
* Checks if a value is a File or Blob
62+
*/
63+
const isFile = (value: unknown): value is File | Blob => {
64+
return (typeof File !== 'undefined' && value instanceof File) || (typeof Blob !== 'undefined' && value instanceof Blob);
65+
};
66+
67+
/**
68+
* Checks if variables contain any File or Blob objects
69+
*/
70+
const hasFiles = (variables: Variables | undefined): boolean => {
71+
if (!variables) {
72+
return false;
73+
}
74+
75+
const check = (value: unknown): boolean => {
76+
if (isFile(value)) {
77+
return true;
78+
}
79+
if (Array.isArray(value)) {
80+
return value.some(check);
81+
}
82+
if (value !== null && typeof value === 'object') {
83+
return Object.values(value).some(check);
84+
}
85+
return false;
86+
};
87+
88+
return check(variables);
89+
}
90+
91+
/**
92+
* Recursively extracts files from variables and returns the cleaned variables
93+
* along with file mappings for the multipart request spec.
94+
*
95+
* @param variables - The original variables object
96+
* @param path - Current path in the object (for building variable paths)
97+
* @returns Object with cleaned variables (files replaced with null) and file mappings
98+
*/
99+
const extractFiles = (
100+
variables: Variables,
101+
path = 'variables'
102+
): { cleanedVariables: Variables; files: FileEntry[] } => {
103+
const files: FileEntry[] = [];
104+
105+
const processValue = (value: unknown, currentPath: string): unknown => {
106+
if (isFile(value)) {
107+
files.push({ path: currentPath, file: value });
108+
return null; // Replace file with null per the GraphQL multipart request specification
109+
}
110+
111+
if (Array.isArray(value)) {
112+
return value.map((item, index) => processValue(item, `${currentPath}.${index}`));
113+
}
114+
115+
if (value === null || typeof value !== 'object') {
116+
return value;
117+
}
118+
119+
const result: Record<string, unknown> = {};
120+
for (const [key, val] of Object.entries(value)) {
121+
result[key] = processValue(val, `${currentPath}.${key}`);
122+
}
123+
return result;
124+
};
125+
126+
const cleanedVariables = processValue(variables, path) as Variables;
127+
return { cleanedVariables, files };
128+
}
129+
130+
/**
131+
* Creates a FormData object for GraphQL multipart file upload requests
132+
* following the GraphQL multipart request specification.
133+
*/
134+
const createMultipartFormData = (query: string, variables: Variables, files: FileEntry[]): FormData => {
135+
const formData = new FormData();
136+
137+
formData.append('query', query);
138+
formData.append('variables', JSON.stringify(variables));
139+
140+
const map: Record<string, string[]> = {};
141+
for (const [index, entry] of Object.entries(files)) {
142+
map[String(index)] = [entry.path];
143+
}
144+
145+
formData.append('map', JSON.stringify(map));
146+
147+
148+
for (const [index, entry] of Object.entries(files)) {
149+
formData.append(String(index), entry.file);
150+
}
151+
152+
return formData;
153+
}
154+

packages/api/lib/seamless-api-client.ts renamed to packages/api/lib/api-client/seamless-api-client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ApiVersionType, DEFAULT_VERSION, QueryVariables } from './constants';
2-
import { SeamlessApiClientError } from './errors/seamless-api-client-error';
1+
import { ApiVersionType, DEFAULT_VERSION, QueryVariables } from '../constants';
2+
import { SeamlessApiClientError } from '../errors/seamless-api-client-error';
33

44
export { SeamlessApiClientError };
55

packages/api/lib/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
export * from './generated/sdk';
22
export * from './api-client';
3-
export * from './seamless-api-client';
43
export * from './constants/index';

packages/api/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mondaydotcomorg/api",
3-
"version": "13.0.0",
3+
"version": "14.0.0",
44
"description": "monday.com API client",
55
"main": "dist/cjs/index.js",
66
"module": "dist/esm/index.js",
@@ -18,7 +18,7 @@
1818
"update-versions": "yarn build && node dist/scripts/update-versions.js"
1919
},
2020
"engines": {
21-
"node": ">= 16.20.0"
21+
"node": ">= 18.0.0"
2222
},
2323
"author": "monday.com Api Team",
2424
"license": "MIT",
@@ -47,7 +47,7 @@
4747
"@types/node": "^20.11.18",
4848
"jest": "^29.7.0",
4949
"moment": "^2.30.1",
50-
"nock": "^13.5.0",
50+
"undici": "^6.21.0",
5151
"rollup": "^2.79.1",
5252
"rollup-plugin-delete": "^2.0.0",
5353
"rollup-plugin-dts": "^4.2.3",

0 commit comments

Comments
 (0)