Skip to content

Commit 57a13ae

Browse files
feat(attachments): add client for uploading file attachments
Add an `attachments` client exposing `api.attachments.upload(...)`, which uploads a file via `POST /attachments/upload` (multipart/form-data) and returns a validated `Attachment` ready to pass into the `attachments` array of `comments.createComment`, `conversationMessages.createMessage`, etc. The upload accepts a `Blob`/`File` or a `Uint8Array` of raw bytes, and normalises each to a `Blob` so the request body works unchanged in the browser and in Node via the global `FormData`. A Node `Buffer` is a `Uint8Array`, so `await readFile(path)` works directly. The `file` part is sent alongside the canonical `file_name`, `file_size`, `attachment_id`, and `underlying_type` fields. The `attachment_id` is minted via the SDK's base58 uuidv7 `resolveCreateId`, matching every other create call, and is caller-overridable. Ported from Doist/twist-sdk-typescript#141. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8e3a2f1 commit 57a13ae

8 files changed

Lines changed: 386 additions & 0 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { HttpResponse, http } from 'msw'
2+
import { apiUrl } from '../testUtils/msw-handlers'
3+
import { server } from '../testUtils/msw-setup'
4+
import { TEST_API_TOKEN } from '../testUtils/test-defaults'
5+
import { generateId, isValidUuidV7Base58 } from '../utils/uuidv7'
6+
import { AttachmentsClient } from './attachments-client'
7+
8+
const UPLOAD_URL = apiUrl('api/v1/attachments/upload')
9+
10+
const mockAttachmentResponse = {
11+
attachment_id: 'abc-123',
12+
url_type: 'file',
13+
file_name: 'diagram.png',
14+
file_size: 11,
15+
underlying_type: 'image/png',
16+
url: 'https://comms.todoist.com/attachments/abc-123/diagram.png',
17+
upload_state: 'uploaded',
18+
}
19+
20+
describe('AttachmentsClient', () => {
21+
let client: AttachmentsClient
22+
23+
beforeEach(() => {
24+
client = new AttachmentsClient({ apiToken: TEST_API_TOKEN })
25+
})
26+
27+
describe('upload', () => {
28+
it('uploads a Buffer with the canonical multipart fields', async () => {
29+
let capturedForm: FormData | undefined
30+
let capturedAuth: string | null = null
31+
let capturedContentType: string | null = null
32+
33+
server.use(
34+
http.post(UPLOAD_URL, async ({ request }) => {
35+
capturedAuth = request.headers.get('Authorization')
36+
capturedContentType = request.headers.get('Content-Type')
37+
capturedForm = await request.formData()
38+
return HttpResponse.json(mockAttachmentResponse)
39+
}),
40+
)
41+
42+
const result = await client.upload({
43+
file: Buffer.from('hello world'),
44+
fileName: 'diagram.png',
45+
})
46+
47+
expect(capturedAuth).toBe(`Bearer ${TEST_API_TOKEN}`)
48+
// multipart boundary content-type, never application/json
49+
expect(capturedContentType).toContain('multipart/form-data')
50+
51+
const file = capturedForm?.get('file')
52+
expect(file).toBeInstanceOf(Blob)
53+
expect(capturedForm?.get('file_name')).toBe('diagram.png')
54+
expect(capturedForm?.get('file_size')).toBe('11')
55+
expect(capturedForm?.get('underlying_type')).toBe('image/png')
56+
// A valid ID is generated when none is supplied.
57+
const attachmentId = capturedForm?.get('attachment_id')
58+
expect(isValidUuidV7Base58(attachmentId)).toBe(true)
59+
60+
// Response is camel-cased and validated.
61+
expect(result.attachmentId).toBe('abc-123')
62+
expect(result.fileName).toBe('diagram.png')
63+
expect(result.url).toBe('https://comms.todoist.com/attachments/abc-123/diagram.png')
64+
})
65+
66+
it('uses a caller-supplied attachmentId', async () => {
67+
let capturedForm: FormData | undefined
68+
const callerId = generateId()
69+
70+
server.use(
71+
http.post(UPLOAD_URL, async ({ request }) => {
72+
capturedForm = await request.formData()
73+
return HttpResponse.json({
74+
...mockAttachmentResponse,
75+
attachment_id: callerId,
76+
})
77+
}),
78+
)
79+
80+
const result = await client.upload({
81+
file: Buffer.from('data'),
82+
fileName: 'notes.txt',
83+
attachmentId: callerId,
84+
})
85+
86+
expect(capturedForm?.get('attachment_id')).toBe(callerId)
87+
expect(result.attachmentId).toBe(callerId)
88+
})
89+
90+
it('uploads a Blob and infers the file name and type', async () => {
91+
let capturedForm: FormData | undefined
92+
93+
server.use(
94+
http.post(UPLOAD_URL, async ({ request }) => {
95+
capturedForm = await request.formData()
96+
return HttpResponse.json(mockAttachmentResponse)
97+
}),
98+
)
99+
100+
const blob = new File([new Uint8Array([1, 2, 3, 4])], 'photo.jpg', {
101+
type: 'image/jpeg',
102+
})
103+
104+
await client.upload({ file: blob })
105+
106+
expect(capturedForm?.get('file_name')).toBe('photo.jpg')
107+
expect(capturedForm?.get('file_size')).toBe('4')
108+
expect(capturedForm?.get('underlying_type')).toBe('image/jpeg')
109+
})
110+
111+
it('uploads a Uint8Array', async () => {
112+
let capturedForm: FormData | undefined
113+
114+
server.use(
115+
http.post(UPLOAD_URL, async ({ request }) => {
116+
capturedForm = await request.formData()
117+
return HttpResponse.json(mockAttachmentResponse)
118+
}),
119+
)
120+
121+
await client.upload({ file: new Uint8Array([1, 2, 3]), fileName: 'bytes.bin' })
122+
123+
expect(capturedForm?.get('file')).toBeInstanceOf(Blob)
124+
expect(capturedForm?.get('file_name')).toBe('bytes.bin')
125+
expect(capturedForm?.get('file_size')).toBe('3')
126+
expect(capturedForm?.get('underlying_type')).toBe('application/octet-stream')
127+
})
128+
129+
it('throws when uploading raw bytes without a fileName', async () => {
130+
await expect(client.upload({ file: Buffer.from('x') })).rejects.toThrow(
131+
'fileName is required when uploading raw bytes',
132+
)
133+
})
134+
})
135+
})

src/clients/attachments-client.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { ENDPOINT_ATTACHMENTS } from '../consts/endpoints'
2+
import { type Attachment, AttachmentSchema } from '../types/entities'
3+
import type { UploadAttachmentArgs } from '../types/requests'
4+
import { uploadMultipartFile } from '../utils/multipart-upload'
5+
import { resolveCreateId } from '../utils/uuidv7'
6+
import { BaseClient } from './base-client'
7+
8+
/**
9+
* Client for uploading file attachments to Comms.
10+
*
11+
* Attachments are uploaded independently, then referenced by passing the returned
12+
* {@link Attachment} into the `attachments` array of `comments.createComment`,
13+
* `conversationMessages.createMessage`, and similar calls.
14+
*/
15+
export class AttachmentsClient extends BaseClient {
16+
/**
17+
* Uploads a file and returns the created {@link Attachment}.
18+
*
19+
* Mirrors the canonical multipart upload: `POST /attachments/upload` with the `file`
20+
* binary plus `file_name`, `file_size`, `attachment_id`, and `underlying_type` form
21+
* fields.
22+
*
23+
* @param args - The file to upload and optional metadata.
24+
* @returns The created attachment, ready to attach to a comment or message.
25+
*
26+
* @example
27+
* ```typescript
28+
* import { readFile } from 'node:fs/promises'
29+
*
30+
* const attachment = await api.attachments.upload({
31+
* file: await readFile('./diagram.png'),
32+
* fileName: 'diagram.png',
33+
* })
34+
*
35+
* await api.comments.createComment({
36+
* threadId: '7YpL3oZ4kZ9vP7Q1tR2sX3z',
37+
* content: 'See attached',
38+
* attachments: [attachment],
39+
* })
40+
* ```
41+
*/
42+
async upload(args: UploadAttachmentArgs): Promise<Attachment> {
43+
const data = await uploadMultipartFile<unknown>({
44+
baseUrl: this.getBaseUri(),
45+
authToken: this.apiToken,
46+
endpoint: `${ENDPOINT_ATTACHMENTS}/upload`,
47+
file: args.file,
48+
fileName: args.fileName,
49+
contentType: args.contentType,
50+
additionalFields: {
51+
attachment_id: resolveCreateId(args.attachmentId),
52+
},
53+
customFetch: this.customFetch,
54+
})
55+
56+
return AttachmentSchema.parse(data)
57+
}
58+
}

src/comms-api.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ describe('CommsApi', () => {
1717
expect(api.inbox).toBeDefined()
1818
expect(api.reactions).toBeDefined()
1919
expect(api.search).toBeDefined()
20+
expect(api.attachments).toBeDefined()
2021
})
2122

2223
it('accepts a custom base URL', () => {

src/comms-api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AttachmentsClient } from './clients/attachments-client'
12
import { ChannelsClient } from './clients/channels-client'
23
import { CommentsClient } from './clients/comments-client'
34
import { ConversationMessagesClient } from './clients/conversation-messages-client'
@@ -47,6 +48,7 @@ export class CommsApi {
4748
public inbox: InboxClient
4849
public reactions: ReactionsClient
4950
public search: SearchClient
51+
public attachments: AttachmentsClient
5052

5153
/**
5254
* Creates a new Comms API client.
@@ -74,6 +76,7 @@ export class CommsApi {
7476
this.inbox = new InboxClient(clientConfig)
7577
this.reactions = new ReactionsClient(clientConfig)
7678
this.search = new SearchClient(clientConfig)
79+
this.attachments = new AttachmentsClient(clientConfig)
7780
}
7881

7982
/**

src/consts/endpoints.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ export const ENDPOINT_INBOX = 'inbox'
3333
export const ENDPOINT_REACTIONS = 'reactions'
3434
export const ENDPOINT_SEARCH = 'search'
3535
export const ENDPOINT_CONVERSATION_MESSAGES = 'conversation_messages'
36+
export const ENDPOINT_ATTACHMENTS = 'attachments'

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './authentication'
2+
export { AttachmentsClient } from './clients/attachments-client'
23
export { ChannelsClient } from './clients/channels-client'
34
export { CommentsClient } from './clients/comments-client'
45
export { ConversationMessagesClient } from './clients/conversation-messages-client'

src/types/requests.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod'
2+
import type { UploadFile } from '../utils/multipart-upload'
23
import { type Attachment, AttachmentSchema } from './entities'
34
import { NOTIFY_AUDIENCES } from './enums'
45

@@ -422,3 +423,21 @@ export type GetUserLocalTimeArgs = {
422423
workspaceId: number
423424
userId: number
424425
}
426+
427+
// Attachments
428+
export type UploadAttachmentArgs = {
429+
/**
430+
* The file to upload. Accepts a `Blob`/`File` (browser) or a `Uint8Array` of raw
431+
* bytes. A Node `Buffer` is a `Uint8Array`, so `await readFile(path)` works directly.
432+
*/
433+
file: UploadFile
434+
/**
435+
* File name. Required when `file` is a `Uint8Array`; inferred from the `File.name`
436+
* otherwise.
437+
*/
438+
fileName?: string
439+
/** MIME type. Defaults to the `Blob`'s type or one inferred from the file extension. */
440+
contentType?: string
441+
/** Attachment ID to use. A random ID is generated when omitted. */
442+
attachmentId?: string
443+
}

0 commit comments

Comments
 (0)