Skip to content

Commit 61e90e0

Browse files
feat: Add file upload support (#370)
Co-authored-by: Claude <[email protected]>
1 parent c8abb07 commit 61e90e0

File tree

7 files changed

+365
-0
lines changed

7 files changed

+365
-0
lines changed

package-lock.json

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"axios-retry": "^4.0.0",
3030
"camelcase": "6.3.0",
3131
"emoji-regex": "10.6.0",
32+
"form-data": "4.0.4",
3233
"ts-custom-error": "^3.2.0",
3334
"uuid": "11.1.0",
3435
"zod": "4.1.12"

src/TodoistApi.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
Attachment,
23
PersonalProject,
34
WorkspaceProject,
45
Label,
@@ -47,6 +48,8 @@ import {
4748
SearchCompletedTasksArgs,
4849
GetActivityLogsArgs,
4950
GetActivityLogsResponse,
51+
UploadFileArgs,
52+
DeleteUploadArgs,
5053
} from './types/requests'
5154
import { request, isSuccess } from './restClient'
5255
import {
@@ -75,8 +78,10 @@ import {
7578
ENDPOINT_REST_USER,
7679
ENDPOINT_REST_PRODUCTIVITY,
7780
ENDPOINT_REST_ACTIVITIES,
81+
ENDPOINT_REST_UPLOADS,
7882
} from './consts/endpoints'
7983
import {
84+
validateAttachment,
8085
validateComment,
8186
validateCommentArray,
8287
validateCurrentUser,
@@ -93,6 +98,10 @@ import {
9398
validateActivityEventArray,
9499
} from './utils/validators'
95100
import { formatDateToYYYYMMDD } from './utils/urlHelpers'
101+
import FormData from 'form-data'
102+
import { createReadStream } from 'fs'
103+
import { basename } from 'path'
104+
import axios from 'axios'
96105
import { normalizeObjectTypeForApi, denormalizeObjectTypeFromApi } from './utils/activity-helpers'
97106
import { z } from 'zod'
98107

@@ -1134,4 +1143,114 @@ export class TodoistApi {
11341143
nextCursor,
11351144
}
11361145
}
1146+
1147+
/**
1148+
* Uploads a file and returns attachment metadata.
1149+
* This creates an upload record that can be referenced in tasks or comments.
1150+
*
1151+
* @param args - Upload parameters including file content, filename, and optional project ID.
1152+
* @param requestId - Optional custom identifier for the request.
1153+
* @returns A promise that resolves to the uploaded file's attachment metadata.
1154+
*
1155+
* @example
1156+
* ```typescript
1157+
* // Upload from a file path
1158+
* const upload = await api.uploadFile({
1159+
* file: '/path/to/document.pdf',
1160+
* projectId: '12345'
1161+
* })
1162+
*
1163+
* // Upload from a Buffer
1164+
* const buffer = fs.readFileSync('/path/to/document.pdf')
1165+
* const upload = await api.uploadFile({
1166+
* file: buffer,
1167+
* fileName: 'document.pdf', // Required for Buffer/Stream
1168+
* projectId: '12345'
1169+
* })
1170+
*
1171+
* // Use the returned fileUrl in a comment
1172+
* await api.addComment({
1173+
* content: 'See attached document',
1174+
* taskId: '67890',
1175+
* attachment: {
1176+
* fileUrl: upload.fileUrl,
1177+
* fileName: upload.fileName,
1178+
* fileType: upload.fileType,
1179+
* resourceType: upload.resourceType
1180+
* }
1181+
* })
1182+
* ```
1183+
*/
1184+
async uploadFile(args: UploadFileArgs, requestId?: string): Promise<Attachment> {
1185+
const form = new FormData()
1186+
1187+
// Determine file type and add to form data
1188+
if (typeof args.file === 'string') {
1189+
// File path - create read stream
1190+
const filePath = args.file
1191+
const fileName = args.fileName || basename(filePath)
1192+
form.append('file', createReadStream(filePath), fileName)
1193+
} else if (Buffer.isBuffer(args.file)) {
1194+
// Buffer - require fileName
1195+
if (!args.fileName) {
1196+
throw new Error('fileName is required when uploading from a Buffer')
1197+
}
1198+
form.append('file', args.file, args.fileName)
1199+
} else {
1200+
// Stream - require fileName
1201+
if (!args.fileName) {
1202+
throw new Error('fileName is required when uploading from a stream')
1203+
}
1204+
form.append('file', args.file, args.fileName)
1205+
}
1206+
1207+
// Add optional project_id as a form field
1208+
if (args.projectId) {
1209+
form.append('project_id', args.projectId)
1210+
}
1211+
1212+
// Build the full URL
1213+
const url = `${this.syncApiBase}${ENDPOINT_REST_UPLOADS}`
1214+
1215+
// Prepare headers
1216+
const headers: Record<string, string> = {
1217+
Authorization: `Bearer ${this.authToken}`,
1218+
...form.getHeaders(),
1219+
}
1220+
1221+
if (requestId) {
1222+
headers['X-Request-Id'] = requestId
1223+
}
1224+
1225+
// Make the request using axios directly
1226+
const response = await axios.post<Attachment>(url, form, { headers })
1227+
1228+
return validateAttachment(response.data)
1229+
}
1230+
1231+
/**
1232+
* Deletes an uploaded file by its URL.
1233+
*
1234+
* @param args - The file URL to delete.
1235+
* @param requestId - Optional custom identifier for the request.
1236+
* @returns A promise that resolves to `true` if deletion was successful.
1237+
*
1238+
* @example
1239+
* ```typescript
1240+
* await api.deleteUpload({
1241+
* fileUrl: 'https://cdn.todoist.com/...'
1242+
* })
1243+
* ```
1244+
*/
1245+
async deleteUpload(args: DeleteUploadArgs, requestId?: string): Promise<boolean> {
1246+
const response = await request(
1247+
'DELETE',
1248+
this.syncApiBase,
1249+
ENDPOINT_REST_UPLOADS,
1250+
this.authToken,
1251+
args,
1252+
requestId,
1253+
)
1254+
return isSuccess(response)
1255+
}
11371256
}

src/TodoistApi.uploads.test.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { TodoistApi } from './TodoistApi'
2+
import { setupRestClientMock } from './testUtils/mocks'
3+
import { getSyncBaseUri } from './consts/endpoints'
4+
import axios from 'axios'
5+
import * as fs from 'fs'
6+
import { Readable } from 'stream'
7+
8+
// Mock axios
9+
jest.mock('axios')
10+
const mockedAxios = axios as jest.Mocked<typeof axios>
11+
12+
// Mock fs
13+
jest.mock('fs')
14+
const mockedFs = fs as jest.Mocked<typeof fs>
15+
16+
describe('TodoistApi uploads', () => {
17+
describe('uploadFile', () => {
18+
const mockUploadResult = {
19+
fileUrl: 'https://cdn.todoist.com/uploads/test-file.pdf',
20+
fileName: 'test-file.pdf',
21+
fileSize: 1024,
22+
fileType: 'application/pdf',
23+
resourceType: 'file',
24+
uploadState: 'completed' as const,
25+
image: null,
26+
imageWidth: null,
27+
imageHeight: null,
28+
}
29+
30+
beforeEach(() => {
31+
jest.clearAllMocks()
32+
mockedAxios.post.mockResolvedValue({
33+
data: mockUploadResult,
34+
})
35+
})
36+
37+
test('uploads file from Buffer with fileName', async () => {
38+
const api = new TodoistApi('token')
39+
const buffer = Buffer.from('test file content')
40+
41+
const result = await api.uploadFile({
42+
file: buffer,
43+
fileName: 'test-file.pdf',
44+
projectId: '12345',
45+
})
46+
47+
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
48+
const [url, , config] = mockedAxios.post.mock.calls[0]
49+
50+
expect(url).toBe(`${getSyncBaseUri()}uploads`)
51+
expect(config?.headers?.Authorization).toBe('Bearer token')
52+
expect(result).toEqual(mockUploadResult)
53+
})
54+
55+
test('uploads file from file path', async () => {
56+
const mockStream = new Readable()
57+
mockedFs.createReadStream.mockReturnValue(mockStream as fs.ReadStream)
58+
59+
const api = new TodoistApi('token')
60+
61+
const result = await api.uploadFile({
62+
file: '/path/to/document.pdf',
63+
projectId: '12345',
64+
})
65+
66+
expect(mockedFs.createReadStream).toHaveBeenCalledWith('/path/to/document.pdf')
67+
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
68+
expect(result).toEqual(mockUploadResult)
69+
})
70+
71+
test('uploads file from file path with custom fileName', async () => {
72+
const mockStream = new Readable()
73+
mockedFs.createReadStream.mockReturnValue(mockStream as fs.ReadStream)
74+
75+
const api = new TodoistApi('token')
76+
77+
await api.uploadFile({
78+
file: '/path/to/document.pdf',
79+
fileName: 'custom-name.pdf',
80+
})
81+
82+
expect(mockedFs.createReadStream).toHaveBeenCalledWith('/path/to/document.pdf')
83+
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
84+
})
85+
86+
test('uploads file from stream with fileName', async () => {
87+
const mockStream = new Readable()
88+
const api = new TodoistApi('token')
89+
90+
await api.uploadFile({
91+
file: mockStream,
92+
fileName: 'stream-file.pdf',
93+
})
94+
95+
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
96+
})
97+
98+
test.each([
99+
{
100+
description: 'Buffer',
101+
file: Buffer.from('test file content'),
102+
expectedError: 'fileName is required when uploading from a Buffer',
103+
},
104+
{
105+
description: 'stream',
106+
file: new Readable(),
107+
expectedError: 'fileName is required when uploading from a stream',
108+
},
109+
])(
110+
'throws error when $description provided without fileName',
111+
async ({ file, expectedError }) => {
112+
const api = new TodoistApi('token')
113+
114+
await expect(
115+
api.uploadFile({
116+
file,
117+
}),
118+
).rejects.toThrow(expectedError)
119+
},
120+
)
121+
122+
test('uploads file with requestId', async () => {
123+
const api = new TodoistApi('token')
124+
const buffer = Buffer.from('test file content')
125+
const requestId = 'test-request-id'
126+
127+
await api.uploadFile(
128+
{
129+
file: buffer,
130+
fileName: 'test.pdf',
131+
},
132+
requestId,
133+
)
134+
135+
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
136+
const [, , config] = mockedAxios.post.mock.calls[0]
137+
expect(config?.headers?.['X-Request-Id']).toBe(requestId)
138+
})
139+
140+
test('uploads file without projectId', async () => {
141+
const api = new TodoistApi('token')
142+
const buffer = Buffer.from('test file content')
143+
144+
await api.uploadFile({
145+
file: buffer,
146+
fileName: 'test.pdf',
147+
})
148+
149+
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
150+
})
151+
})
152+
153+
describe('deleteUpload', () => {
154+
test('deletes upload successfully', async () => {
155+
const requestMock = setupRestClientMock('ok', 200)
156+
const api = new TodoistApi('token')
157+
158+
const result = await api.deleteUpload({
159+
fileUrl: 'https://cdn.todoist.com/uploads/test-file.pdf',
160+
})
161+
162+
expect(requestMock).toHaveBeenCalledTimes(1)
163+
expect(requestMock).toHaveBeenCalledWith(
164+
'DELETE',
165+
getSyncBaseUri(),
166+
'uploads',
167+
'token',
168+
{
169+
fileUrl: 'https://cdn.todoist.com/uploads/test-file.pdf',
170+
},
171+
undefined,
172+
)
173+
expect(result).toBe(true)
174+
})
175+
176+
test('deletes upload with requestId', async () => {
177+
const requestMock = setupRestClientMock('ok', 200)
178+
const api = new TodoistApi('token')
179+
const requestId = 'test-request-id'
180+
181+
const result = await api.deleteUpload(
182+
{
183+
fileUrl: 'https://cdn.todoist.com/uploads/test-file.pdf',
184+
},
185+
requestId,
186+
)
187+
188+
expect(requestMock).toHaveBeenCalledTimes(1)
189+
expect(requestMock).toHaveBeenCalledWith(
190+
'DELETE',
191+
getSyncBaseUri(),
192+
'uploads',
193+
'token',
194+
{
195+
fileUrl: 'https://cdn.todoist.com/uploads/test-file.pdf',
196+
},
197+
requestId,
198+
)
199+
expect(result).toBe(true)
200+
})
201+
})
202+
})

src/consts/endpoints.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const ENDPOINT_REST_PROJECT_COLLABORATORS = 'collaborators'
3939
export const ENDPOINT_REST_USER = 'user'
4040
export const ENDPOINT_REST_PRODUCTIVITY = ENDPOINT_REST_TASKS + '/completed/stats'
4141
export const ENDPOINT_REST_ACTIVITIES = 'activities'
42+
export const ENDPOINT_REST_UPLOADS = 'uploads'
4243
export const PROJECT_ARCHIVE = 'archive'
4344
export const PROJECT_UNARCHIVE = 'unarchive'
4445

0 commit comments

Comments
 (0)