@doist/comms-sdk 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -45
- package/dist/cjs/authentication.js +211 -0
- package/dist/cjs/clients/add-comment-helper.js +70 -0
- package/dist/cjs/clients/base-client.js +25 -0
- package/dist/cjs/clients/channels-client.js +200 -0
- package/dist/cjs/clients/comments-client.js +159 -0
- package/dist/cjs/clients/conversation-messages-client.js +158 -0
- package/dist/cjs/clients/conversations-client.js +243 -0
- package/dist/cjs/clients/groups-client.js +164 -0
- package/dist/cjs/clients/inbox-client.js +171 -0
- package/dist/cjs/clients/reactions-client.js +97 -0
- package/dist/cjs/clients/search-client.js +138 -0
- package/dist/cjs/clients/threads-client.js +330 -0
- package/dist/cjs/clients/users-client.js +326 -0
- package/dist/cjs/clients/workspace-users-client.js +240 -0
- package/dist/cjs/clients/workspaces-client.js +166 -0
- package/dist/cjs/comms-api.js +66 -0
- package/dist/cjs/consts/endpoints.js +32 -0
- package/dist/cjs/index.js +48 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/testUtils/msw-handlers.js +51 -0
- package/dist/cjs/testUtils/msw-setup.js +21 -0
- package/dist/cjs/testUtils/obsidian-fetch-adapter.js +53 -0
- package/dist/cjs/testUtils/test-defaults.js +104 -0
- package/dist/cjs/transport/fetch-with-retry.js +136 -0
- package/dist/cjs/transport/http-client.js +56 -0
- package/dist/cjs/transport/http-dispatcher.js +143 -0
- package/dist/cjs/types/api-version.js +8 -0
- package/dist/cjs/types/entities.js +411 -0
- package/dist/cjs/types/enums.js +37 -0
- package/dist/cjs/types/errors.js +12 -0
- package/dist/cjs/types/http.js +4 -0
- package/dist/cjs/types/index.js +22 -0
- package/dist/cjs/types/requests.js +116 -0
- package/dist/cjs/utils/case-conversion.js +54 -0
- package/dist/cjs/utils/index.js +19 -0
- package/dist/cjs/utils/timestamp-conversion.js +49 -0
- package/dist/cjs/utils/url-helpers.js +131 -0
- package/dist/cjs/utils/uuidv7.js +174 -0
- package/dist/esm/authentication.js +203 -0
- package/dist/esm/clients/add-comment-helper.js +67 -0
- package/dist/esm/clients/base-client.js +21 -0
- package/dist/esm/clients/channels-client.js +196 -0
- package/dist/esm/clients/comments-client.js +155 -0
- package/dist/esm/clients/conversation-messages-client.js +154 -0
- package/dist/esm/clients/conversations-client.js +239 -0
- package/dist/esm/clients/groups-client.js +160 -0
- package/dist/esm/clients/inbox-client.js +167 -0
- package/dist/esm/clients/reactions-client.js +93 -0
- package/dist/esm/clients/search-client.js +134 -0
- package/dist/esm/clients/threads-client.js +326 -0
- package/dist/esm/clients/users-client.js +322 -0
- package/dist/esm/clients/workspace-users-client.js +236 -0
- package/dist/esm/clients/workspaces-client.js +162 -0
- package/dist/esm/comms-api.js +62 -0
- package/dist/esm/consts/endpoints.js +28 -0
- package/dist/esm/index.js +17 -0
- package/dist/esm/testUtils/msw-handlers.js +45 -0
- package/dist/esm/testUtils/msw-setup.js +18 -0
- package/dist/esm/testUtils/obsidian-fetch-adapter.js +50 -0
- package/dist/esm/testUtils/test-defaults.js +101 -0
- package/dist/esm/transport/fetch-with-retry.js +133 -0
- package/dist/esm/transport/http-client.js +51 -0
- package/dist/esm/transport/http-dispatcher.js +104 -0
- package/dist/esm/types/api-version.js +5 -0
- package/dist/esm/types/entities.js +408 -0
- package/dist/esm/types/enums.js +34 -0
- package/dist/esm/types/errors.js +8 -0
- package/dist/esm/types/http.js +1 -0
- package/dist/esm/types/index.js +6 -0
- package/dist/esm/types/requests.js +113 -0
- package/dist/esm/utils/case-conversion.js +47 -0
- package/dist/esm/utils/index.js +3 -0
- package/dist/esm/utils/timestamp-conversion.js +45 -0
- package/dist/esm/utils/url-helpers.js +112 -0
- package/dist/esm/utils/uuidv7.js +163 -0
- package/dist/types/authentication.d.ts +160 -0
- package/dist/types/clients/add-comment-helper.d.ts +29 -0
- package/dist/types/clients/base-client.d.ts +28 -0
- package/dist/types/clients/channels-client.d.ts +208 -0
- package/dist/types/clients/comments-client.d.ts +224 -0
- package/dist/types/clients/conversation-messages-client.d.ts +198 -0
- package/dist/types/clients/conversations-client.d.ts +346 -0
- package/dist/types/clients/groups-client.d.ts +148 -0
- package/dist/types/clients/inbox-client.d.ts +96 -0
- package/dist/types/clients/reactions-client.d.ts +57 -0
- package/dist/types/clients/search-client.d.ts +70 -0
- package/dist/types/clients/threads-client.d.ts +536 -0
- package/dist/types/clients/users-client.d.ts +250 -0
- package/dist/types/clients/workspace-users-client.d.ts +147 -0
- package/dist/types/clients/workspaces-client.d.ts +152 -0
- package/dist/types/comms-api.d.ts +62 -0
- package/dist/types/consts/endpoints.d.ts +24 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/testUtils/msw-handlers.d.ts +28 -0
- package/dist/types/testUtils/msw-setup.d.ts +1 -0
- package/dist/types/testUtils/obsidian-fetch-adapter.d.ts +29 -0
- package/dist/types/testUtils/test-defaults.d.ts +17 -0
- package/dist/types/transport/fetch-with-retry.d.ts +4 -0
- package/dist/types/transport/http-client.d.ts +13 -0
- package/dist/types/transport/http-dispatcher.d.ts +10 -0
- package/dist/types/types/api-version.d.ts +6 -0
- package/dist/types/types/entities.d.ts +1288 -0
- package/dist/types/types/enums.d.ts +55 -0
- package/dist/types/types/errors.d.ts +6 -0
- package/dist/types/types/http.d.ts +54 -0
- package/dist/types/types/index.d.ts +6 -0
- package/dist/types/types/requests.d.ts +366 -0
- package/dist/types/utils/case-conversion.d.ts +8 -0
- package/dist/types/utils/index.d.ts +3 -0
- package/dist/types/utils/timestamp-conversion.d.ts +13 -0
- package/dist/types/utils/url-helpers.d.ts +88 -0
- package/dist/types/utils/uuidv7.d.ts +40 -0
- package/package.json +91 -8
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ENDPOINT_WORKSPACES } from '../consts/endpoints.js';
|
|
3
|
+
import { request } from '../transport/http-client.js';
|
|
4
|
+
import { ChannelSchema, WorkspaceSchema } from '../types/entities.js';
|
|
5
|
+
import { BaseClient } from './base-client.js';
|
|
6
|
+
export const ChannelListSchema = z.array(ChannelSchema);
|
|
7
|
+
/**
|
|
8
|
+
* Client for `/api/v1/workspaces/`. Workspace IDs are integers. The backend
|
|
9
|
+
* currently rejects any `color` other than `1` on add/update.
|
|
10
|
+
*/
|
|
11
|
+
export class WorkspacesClient extends BaseClient {
|
|
12
|
+
/**
|
|
13
|
+
* Gets all the user's workspaces.
|
|
14
|
+
*
|
|
15
|
+
* @returns An array of all workspaces the user belongs to.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* const workspaces = await api.workspaces.getWorkspaces()
|
|
20
|
+
* workspaces.forEach(ws => console.log(ws.name))
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
getWorkspaces() {
|
|
24
|
+
return request({
|
|
25
|
+
httpMethod: 'GET',
|
|
26
|
+
baseUri: this.getBaseUri(),
|
|
27
|
+
relativePath: `${ENDPOINT_WORKSPACES}/get`,
|
|
28
|
+
apiToken: this.apiToken,
|
|
29
|
+
payload: undefined,
|
|
30
|
+
customFetch: this.customFetch,
|
|
31
|
+
}).then((response) => response.data.map((workspace) => WorkspaceSchema.parse(workspace)));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Gets a single workspace object by id.
|
|
35
|
+
*
|
|
36
|
+
* @param id - The workspace ID.
|
|
37
|
+
* @returns The workspace object.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* const workspace = await api.workspaces.getWorkspace(123)
|
|
42
|
+
* console.log(workspace.name)
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
getWorkspace(id) {
|
|
46
|
+
return request({
|
|
47
|
+
httpMethod: 'GET',
|
|
48
|
+
baseUri: this.getBaseUri(),
|
|
49
|
+
relativePath: `${ENDPOINT_WORKSPACES}/getone`,
|
|
50
|
+
apiToken: this.apiToken,
|
|
51
|
+
payload: { id },
|
|
52
|
+
customFetch: this.customFetch,
|
|
53
|
+
}).then((response) => WorkspaceSchema.parse(response.data));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Gets the user's default workspace.
|
|
57
|
+
*
|
|
58
|
+
* @returns The default workspace object.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* const workspace = await api.workspaces.getDefaultWorkspace()
|
|
63
|
+
* console.log(workspace.name)
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
getDefaultWorkspace() {
|
|
67
|
+
return request({
|
|
68
|
+
httpMethod: 'GET',
|
|
69
|
+
baseUri: this.getBaseUri(),
|
|
70
|
+
relativePath: `${ENDPOINT_WORKSPACES}/get_default`,
|
|
71
|
+
apiToken: this.apiToken,
|
|
72
|
+
payload: undefined,
|
|
73
|
+
customFetch: this.customFetch,
|
|
74
|
+
}).then((response) => WorkspaceSchema.parse(response.data));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Creates a new workspace.
|
|
78
|
+
*
|
|
79
|
+
* @param name - The name of the new workspace.
|
|
80
|
+
* @returns The created workspace object.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* const workspace = await api.workspaces.createWorkspace('My Team')
|
|
85
|
+
* console.log('Created:', workspace.name)
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
createWorkspace(name) {
|
|
89
|
+
return request({
|
|
90
|
+
httpMethod: 'POST',
|
|
91
|
+
baseUri: this.getBaseUri(),
|
|
92
|
+
relativePath: `${ENDPOINT_WORKSPACES}/add`,
|
|
93
|
+
apiToken: this.apiToken,
|
|
94
|
+
payload: { name },
|
|
95
|
+
customFetch: this.customFetch,
|
|
96
|
+
}).then((response) => WorkspaceSchema.parse(response.data));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Updates an existing workspace.
|
|
100
|
+
*
|
|
101
|
+
* @param id - The workspace ID.
|
|
102
|
+
* @param name - The new name for the workspace.
|
|
103
|
+
* @returns The updated workspace object.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* const workspace = await api.workspaces.updateWorkspace(123, 'New Team Name')
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
updateWorkspace(id, name) {
|
|
111
|
+
return request({
|
|
112
|
+
httpMethod: 'POST',
|
|
113
|
+
baseUri: this.getBaseUri(),
|
|
114
|
+
relativePath: `${ENDPOINT_WORKSPACES}/update`,
|
|
115
|
+
apiToken: this.apiToken,
|
|
116
|
+
payload: { id, name },
|
|
117
|
+
customFetch: this.customFetch,
|
|
118
|
+
}).then((response) => WorkspaceSchema.parse(response.data));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Removes a workspace and all its data (not recoverable).
|
|
122
|
+
*
|
|
123
|
+
* @param id - The workspace ID.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```typescript
|
|
127
|
+
* await api.workspaces.removeWorkspace(123)
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
removeWorkspace(id) {
|
|
131
|
+
return request({
|
|
132
|
+
httpMethod: 'POST',
|
|
133
|
+
baseUri: this.getBaseUri(),
|
|
134
|
+
relativePath: `${ENDPOINT_WORKSPACES}/remove`,
|
|
135
|
+
apiToken: this.apiToken,
|
|
136
|
+
payload: { id },
|
|
137
|
+
customFetch: this.customFetch,
|
|
138
|
+
}).then(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Gets the public channels of a workspace.
|
|
142
|
+
*
|
|
143
|
+
* @param id - The workspace ID.
|
|
144
|
+
* @returns An array of public channel objects.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```typescript
|
|
148
|
+
* const channels = await api.workspaces.getPublicChannels(123)
|
|
149
|
+
* channels.forEach(ch => console.log(ch.name))
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
getPublicChannels(id) {
|
|
153
|
+
return request({
|
|
154
|
+
httpMethod: 'GET',
|
|
155
|
+
baseUri: this.getBaseUri(),
|
|
156
|
+
relativePath: `${ENDPOINT_WORKSPACES}/get_public_channels`,
|
|
157
|
+
apiToken: this.apiToken,
|
|
158
|
+
payload: { id },
|
|
159
|
+
customFetch: this.customFetch,
|
|
160
|
+
}).then((response) => ChannelListSchema.parse(response.data));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ChannelsClient } from './clients/channels-client.js';
|
|
2
|
+
import { CommentsClient } from './clients/comments-client.js';
|
|
3
|
+
import { ConversationMessagesClient } from './clients/conversation-messages-client.js';
|
|
4
|
+
import { ConversationsClient } from './clients/conversations-client.js';
|
|
5
|
+
import { GroupsClient } from './clients/groups-client.js';
|
|
6
|
+
import { InboxClient } from './clients/inbox-client.js';
|
|
7
|
+
import { ReactionsClient } from './clients/reactions-client.js';
|
|
8
|
+
import { SearchClient } from './clients/search-client.js';
|
|
9
|
+
import { ThreadsClient } from './clients/threads-client.js';
|
|
10
|
+
import { UsersClient } from './clients/users-client.js';
|
|
11
|
+
import { WorkspaceUsersClient } from './clients/workspace-users-client.js';
|
|
12
|
+
import { WorkspacesClient } from './clients/workspaces-client.js';
|
|
13
|
+
import { closeDefaultDispatcher } from './transport/http-dispatcher.js';
|
|
14
|
+
/**
|
|
15
|
+
* The main API client for interacting with the Comms REST API.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* import { CommsApi } from '@doist/comms-sdk'
|
|
20
|
+
*
|
|
21
|
+
* const api = new CommsApi('your-api-token')
|
|
22
|
+
* const user = await api.users.getSessionUser()
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export class CommsApi {
|
|
26
|
+
/**
|
|
27
|
+
* Creates a new Comms API client.
|
|
28
|
+
*
|
|
29
|
+
* @param authToken - Your Comms API token.
|
|
30
|
+
* @param options - Optional configuration options.
|
|
31
|
+
*/
|
|
32
|
+
constructor(authToken, options) {
|
|
33
|
+
const clientConfig = {
|
|
34
|
+
apiToken: authToken,
|
|
35
|
+
baseUrl: options?.baseUrl,
|
|
36
|
+
version: options?.version,
|
|
37
|
+
customFetch: options?.customFetch,
|
|
38
|
+
};
|
|
39
|
+
this.users = new UsersClient(clientConfig);
|
|
40
|
+
this.workspaces = new WorkspacesClient(clientConfig);
|
|
41
|
+
this.workspaceUsers = new WorkspaceUsersClient(clientConfig);
|
|
42
|
+
this.channels = new ChannelsClient(clientConfig);
|
|
43
|
+
this.threads = new ThreadsClient(clientConfig);
|
|
44
|
+
this.groups = new GroupsClient(clientConfig);
|
|
45
|
+
this.conversations = new ConversationsClient(clientConfig);
|
|
46
|
+
this.comments = new CommentsClient(clientConfig);
|
|
47
|
+
this.conversationMessages = new ConversationMessagesClient(clientConfig);
|
|
48
|
+
this.inbox = new InboxClient(clientConfig);
|
|
49
|
+
this.reactions = new ReactionsClient(clientConfig);
|
|
50
|
+
this.search = new SearchClient(clientConfig);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Drains the SDK's process-global connection pool. CLIs and scripts
|
|
54
|
+
* should `await api.close()` before exit so Node's event loop empties
|
|
55
|
+
* immediately instead of waiting ~4s on keep-alive. Affects every
|
|
56
|
+
* `CommsApi` and OAuth helper in the same process — it's a
|
|
57
|
+
* process-shutdown gesture, not an instance teardown. Browser-safe.
|
|
58
|
+
*/
|
|
59
|
+
async close() {
|
|
60
|
+
await closeDefaultDispatcher();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { DEFAULT_API_VERSION } from '../types/api-version.js';
|
|
2
|
+
const BASE_URI = 'https://comms.todoist.com';
|
|
3
|
+
/**
|
|
4
|
+
* Gets the base URI for Comms API requests.
|
|
5
|
+
*
|
|
6
|
+
* Preserves any path component on `domainBase` so callers can route through
|
|
7
|
+
* a proxy (e.g. `https://proxy.example.com/comms` → `.../comms/api/v1/`).
|
|
8
|
+
*
|
|
9
|
+
* @param version - API version. Defaults to 'v1'.
|
|
10
|
+
* @param domainBase - Custom domain base URL. Defaults to Comms' API domain.
|
|
11
|
+
* @returns Complete base URI with trailing slash (e.g., 'https://comms.todoist.com/api/v1/')
|
|
12
|
+
*/
|
|
13
|
+
export function getCommsBaseUri(version = DEFAULT_API_VERSION, domainBase = BASE_URI) {
|
|
14
|
+
const base = domainBase.endsWith('/') ? domainBase : `${domainBase}/`;
|
|
15
|
+
return new URL(`api/${version}/`, base).toString();
|
|
16
|
+
}
|
|
17
|
+
export const ENDPOINT_USERS = 'users';
|
|
18
|
+
export const ENDPOINT_WORKSPACES = 'workspaces';
|
|
19
|
+
export const ENDPOINT_CHANNELS = 'channels';
|
|
20
|
+
export const ENDPOINT_THREADS = 'threads';
|
|
21
|
+
export const ENDPOINT_GROUPS = 'groups';
|
|
22
|
+
export const ENDPOINT_CONVERSATIONS = 'conversations';
|
|
23
|
+
export const ENDPOINT_COMMENTS = 'comments';
|
|
24
|
+
export const ENDPOINT_NOTIFICATIONS = 'notifications';
|
|
25
|
+
export const ENDPOINT_INBOX = 'inbox';
|
|
26
|
+
export const ENDPOINT_REACTIONS = 'reactions';
|
|
27
|
+
export const ENDPOINT_SEARCH = 'search';
|
|
28
|
+
export const ENDPOINT_CONVERSATION_MESSAGES = 'conversation_messages';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * from './authentication.js';
|
|
2
|
+
export { ChannelsClient } from './clients/channels-client.js';
|
|
3
|
+
export { CommentsClient } from './clients/comments-client.js';
|
|
4
|
+
export { ConversationMessagesClient } from './clients/conversation-messages-client.js';
|
|
5
|
+
export { ConversationsClient } from './clients/conversations-client.js';
|
|
6
|
+
export { GroupsClient } from './clients/groups-client.js';
|
|
7
|
+
export { InboxClient } from './clients/inbox-client.js';
|
|
8
|
+
export { ReactionsClient } from './clients/reactions-client.js';
|
|
9
|
+
export { SearchClient } from './clients/search-client.js';
|
|
10
|
+
export { ThreadsClient } from './clients/threads-client.js';
|
|
11
|
+
export { UsersClient } from './clients/users-client.js';
|
|
12
|
+
export { WorkspaceUsersClient } from './clients/workspace-users-client.js';
|
|
13
|
+
export { WorkspacesClient } from './clients/workspaces-client.js';
|
|
14
|
+
export { CommsApi } from './comms-api.js';
|
|
15
|
+
export { closeDefaultDispatcher } from './transport/http-dispatcher.js';
|
|
16
|
+
export * from './types/index.js';
|
|
17
|
+
export * from './utils/index.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { HttpResponse, http } from 'msw';
|
|
2
|
+
const BASE_URL = 'https://comms.todoist.com';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a successful API response with the given data
|
|
5
|
+
*/
|
|
6
|
+
export function createSuccessResponse(data) {
|
|
7
|
+
return HttpResponse.json(data, { status: 200 });
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Creates an error API response
|
|
11
|
+
*/
|
|
12
|
+
export function createErrorResponse(errorCode, errorMessage, status = 400) {
|
|
13
|
+
return HttpResponse.json({
|
|
14
|
+
error_code: errorCode,
|
|
15
|
+
error_string: errorMessage,
|
|
16
|
+
}, { status });
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Helper to create API endpoint URL
|
|
20
|
+
*/
|
|
21
|
+
export function apiUrl(path) {
|
|
22
|
+
// Remove leading slash if present
|
|
23
|
+
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
|
24
|
+
return `${BASE_URL}/${cleanPath}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Creates a handler for a GET endpoint
|
|
28
|
+
*/
|
|
29
|
+
export function createGetHandler(endpoint, responseData) {
|
|
30
|
+
return http.get(apiUrl(endpoint), () => {
|
|
31
|
+
return createSuccessResponse(responseData);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Creates a handler for a POST endpoint
|
|
36
|
+
*/
|
|
37
|
+
export function createPostHandler(endpoint, responseData) {
|
|
38
|
+
return http.post(apiUrl(endpoint), () => {
|
|
39
|
+
return createSuccessResponse(responseData);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Export http from msw for custom handlers
|
|
44
|
+
*/
|
|
45
|
+
export { http, HttpResponse };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { setupServer } from 'msw/node';
|
|
2
|
+
import { afterAll, afterEach, beforeAll } from 'vitest';
|
|
3
|
+
// Create MSW server instance
|
|
4
|
+
export const server = setupServer();
|
|
5
|
+
// Start server before all tests
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
// Only warn on unhandled requests instead of error, since transport/http-client.test.ts
|
|
8
|
+
// uses direct fetch mocking which bypasses MSW
|
|
9
|
+
server.listen({ onUnhandledRequest: 'warn' });
|
|
10
|
+
});
|
|
11
|
+
// Reset handlers after each test
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
server.resetHandlers();
|
|
14
|
+
});
|
|
15
|
+
// Clean up after all tests
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
server.close();
|
|
18
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a CustomFetch adapter for Obsidian's requestUrl API.
|
|
3
|
+
*
|
|
4
|
+
* This adapter bridges the gap between Obsidian's requestUrl interface and the
|
|
5
|
+
* standard fetch-like interface expected by the Comms API SDK.
|
|
6
|
+
*
|
|
7
|
+
* Key differences handled by this adapter:
|
|
8
|
+
* - Obsidian returns response data as properties (response.json, response.text)
|
|
9
|
+
* while the SDK expects methods (response.json(), response.text())
|
|
10
|
+
* - Obsidian's requestUrl bypasses CORS restrictions that would block standard fetch
|
|
11
|
+
* - Obsidian throws on HTTP errors by default; we set throw: false to handle manually
|
|
12
|
+
* - Obsidian doesn't provide statusText; we default to empty string
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import { requestUrl } from 'obsidian'
|
|
17
|
+
* import { createObsidianFetchAdapter } from './obsidian-fetch-adapter.js'
|
|
18
|
+
*
|
|
19
|
+
* const api = new CommsApi('your-token', {
|
|
20
|
+
* customFetch: createObsidianFetchAdapter(requestUrl)
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @param requestUrl - The Obsidian requestUrl function
|
|
25
|
+
* @returns A CustomFetch function compatible with the Comms API SDK
|
|
26
|
+
*/
|
|
27
|
+
export function createObsidianFetchAdapter(requestUrl) {
|
|
28
|
+
return async (url, options) => {
|
|
29
|
+
// Build the request parameters in Obsidian's format
|
|
30
|
+
const requestParams = {
|
|
31
|
+
url,
|
|
32
|
+
method: options?.method || 'GET',
|
|
33
|
+
headers: options?.headers,
|
|
34
|
+
body: options?.body,
|
|
35
|
+
throw: false, // Don't throw on HTTP errors; let the SDK handle status codes
|
|
36
|
+
};
|
|
37
|
+
// Make the request using Obsidian's requestUrl
|
|
38
|
+
const response = await requestUrl(requestParams);
|
|
39
|
+
// Transform Obsidian's response format to match CustomFetchResponse interface
|
|
40
|
+
return {
|
|
41
|
+
ok: response.status >= 200 && response.status < 300,
|
|
42
|
+
status: response.status,
|
|
43
|
+
statusText: '', // Obsidian doesn't provide statusText
|
|
44
|
+
headers: response.headers,
|
|
45
|
+
// Wrap Obsidian's direct properties as methods returning promises
|
|
46
|
+
text: () => Promise.resolve(response.text),
|
|
47
|
+
json: () => Promise.resolve(response.json),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { getCommsBaseUri } from '../consts/endpoints.js';
|
|
2
|
+
export const TEST_API_TOKEN = 'test-api-token';
|
|
3
|
+
export const TEST_API_BASE_URL = getCommsBaseUri().replace(/\/$/, '');
|
|
4
|
+
// Canonical test IDs — shaped so they pass SDK-side validation.
|
|
5
|
+
export const TEST_CHANNEL_ID = '7YpL3oZ4kZ9vP7Q1tR2sX3y';
|
|
6
|
+
export const TEST_THREAD_ID = '7YpL3oZ4kZ9vP7Q1tR2sX3z';
|
|
7
|
+
export const TEST_COMMENT_ID = '7YpL3oZ4kZ9vP7Q1tR2sX41';
|
|
8
|
+
export const TEST_CONVERSATION_ID = '7YpL3oZ4kZ9vP7Q1tR2sX42';
|
|
9
|
+
export const TEST_MESSAGE_ID = '7YpL3oZ4kZ9vP7Q1tR2sX43';
|
|
10
|
+
export const TEST_GROUP_ID = '7YpL3oZ4kZ9vP7Q1tR2sX44';
|
|
11
|
+
export const mockUser = {
|
|
12
|
+
id: 1,
|
|
13
|
+
email: 'test@example.com',
|
|
14
|
+
fullName: 'Test User',
|
|
15
|
+
shortName: 'TU',
|
|
16
|
+
timezone: 'America/New_York',
|
|
17
|
+
removed: false,
|
|
18
|
+
lang: 'en',
|
|
19
|
+
};
|
|
20
|
+
export const mockWorkspace = {
|
|
21
|
+
id: 1,
|
|
22
|
+
name: 'Test Workspace',
|
|
23
|
+
creator: 1,
|
|
24
|
+
created: new Date('2021-01-01T00:00:00Z'),
|
|
25
|
+
};
|
|
26
|
+
export const mockChannel = {
|
|
27
|
+
id: TEST_CHANNEL_ID,
|
|
28
|
+
name: 'general',
|
|
29
|
+
creator: 1,
|
|
30
|
+
public: true,
|
|
31
|
+
workspaceId: 1,
|
|
32
|
+
archived: false,
|
|
33
|
+
created: new Date('2021-01-01T00:00:00Z'),
|
|
34
|
+
version: 0,
|
|
35
|
+
url: `https://comms.todoist.com/a/1/ch/${TEST_CHANNEL_ID}/`,
|
|
36
|
+
};
|
|
37
|
+
export const mockThread = {
|
|
38
|
+
id: TEST_THREAD_ID,
|
|
39
|
+
title: 'Test Thread',
|
|
40
|
+
content: 'This is a test thread',
|
|
41
|
+
creator: 1,
|
|
42
|
+
channelId: TEST_CHANNEL_ID,
|
|
43
|
+
workspaceId: 1,
|
|
44
|
+
commentCount: 0,
|
|
45
|
+
lastUpdated: new Date('2021-01-01T00:00:00Z'),
|
|
46
|
+
pinned: false,
|
|
47
|
+
posted: new Date('2021-01-01T00:00:00Z'),
|
|
48
|
+
snippet: 'This is a test thread',
|
|
49
|
+
snippetCreator: 1,
|
|
50
|
+
isArchived: false,
|
|
51
|
+
url: `https://comms.todoist.com/a/1/ch/${TEST_CHANNEL_ID}/t/${TEST_THREAD_ID}/`,
|
|
52
|
+
};
|
|
53
|
+
export const mockGroup = {
|
|
54
|
+
id: TEST_GROUP_ID,
|
|
55
|
+
name: 'Test Group',
|
|
56
|
+
workspaceId: 1,
|
|
57
|
+
userIds: [1, 2, 3],
|
|
58
|
+
version: 0,
|
|
59
|
+
};
|
|
60
|
+
export const mockConversation = {
|
|
61
|
+
id: TEST_CONVERSATION_ID,
|
|
62
|
+
workspaceId: 1,
|
|
63
|
+
userIds: [1, 2],
|
|
64
|
+
messageCount: 1,
|
|
65
|
+
lastObjIndex: 0,
|
|
66
|
+
snippet: 'Hello there',
|
|
67
|
+
snippetCreators: [1],
|
|
68
|
+
lastActive: new Date('2021-01-01T00:00:00Z'),
|
|
69
|
+
archived: false,
|
|
70
|
+
created: new Date('2021-01-01T00:00:00Z'),
|
|
71
|
+
creator: 1,
|
|
72
|
+
url: `https://comms.todoist.com/a/1/msg/${TEST_CONVERSATION_ID}/`,
|
|
73
|
+
};
|
|
74
|
+
export const mockComment = {
|
|
75
|
+
id: TEST_COMMENT_ID,
|
|
76
|
+
content: 'This is a comment',
|
|
77
|
+
creator: 1,
|
|
78
|
+
threadId: TEST_THREAD_ID,
|
|
79
|
+
workspaceId: 1,
|
|
80
|
+
channelId: TEST_CHANNEL_ID,
|
|
81
|
+
posted: new Date('2021-01-01T00:00:00Z'),
|
|
82
|
+
url: `https://comms.todoist.com/a/1/ch/${TEST_CHANNEL_ID}/t/${TEST_THREAD_ID}/c/${TEST_COMMENT_ID}`,
|
|
83
|
+
};
|
|
84
|
+
export const mockWorkspaceUser = {
|
|
85
|
+
id: 1,
|
|
86
|
+
fullName: 'Test User',
|
|
87
|
+
email: 'test@example.com',
|
|
88
|
+
userType: 'USER',
|
|
89
|
+
shortName: 'TU',
|
|
90
|
+
firstName: 'Test',
|
|
91
|
+
imageId: null,
|
|
92
|
+
avatarUrls: null,
|
|
93
|
+
dateFormat: null,
|
|
94
|
+
removed: false,
|
|
95
|
+
restricted: null,
|
|
96
|
+
setupPending: null,
|
|
97
|
+
theme: null,
|
|
98
|
+
timeFormat: null,
|
|
99
|
+
timezone: 'America/New_York',
|
|
100
|
+
version: 1,
|
|
101
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { CommsRequestError } from '../types/errors.js';
|
|
2
|
+
import { camelCaseKeys } from '../utils/case-conversion.js';
|
|
3
|
+
import { transformTimestamps } from '../utils/timestamp-conversion.js';
|
|
4
|
+
import { getDefaultDispatcher } from './http-dispatcher.js';
|
|
5
|
+
export async function fetchWithRetry(url, options, maxRetries = 3, customFetch) {
|
|
6
|
+
let lastError;
|
|
7
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
8
|
+
let clearTimeoutFn;
|
|
9
|
+
try {
|
|
10
|
+
let requestSignal = options.signal || undefined;
|
|
11
|
+
if (options.timeout && options.timeout > 0) {
|
|
12
|
+
const timeoutResult = createTimeoutSignal(options.timeout, requestSignal);
|
|
13
|
+
requestSignal = timeoutResult.signal;
|
|
14
|
+
clearTimeoutFn = timeoutResult.clear;
|
|
15
|
+
}
|
|
16
|
+
const response = customFetch
|
|
17
|
+
? await customFetch(url, options)
|
|
18
|
+
: await fetchWithDefaultTransport(url, options, requestSignal);
|
|
19
|
+
const responseText = await response.text();
|
|
20
|
+
let responseData;
|
|
21
|
+
try {
|
|
22
|
+
responseData = responseText ? JSON.parse(responseText) : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
responseData = responseText;
|
|
26
|
+
}
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new CommsRequestError(`Request failed with status ${response.status}`, response.status, responseData);
|
|
29
|
+
}
|
|
30
|
+
const camelCased = camelCaseKeys(responseData);
|
|
31
|
+
const transformed = transformTimestamps(camelCased);
|
|
32
|
+
if (clearTimeoutFn) {
|
|
33
|
+
clearTimeoutFn();
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
data: transformed,
|
|
37
|
+
status: response.status,
|
|
38
|
+
headers: response.headers,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (clearTimeoutFn) {
|
|
43
|
+
clearTimeoutFn();
|
|
44
|
+
}
|
|
45
|
+
lastError = error instanceof Error ? error : new Error('Unknown error');
|
|
46
|
+
if (attempt < maxRetries && isNetworkError(lastError)) {
|
|
47
|
+
const delay = getRetryDelay(attempt + 1);
|
|
48
|
+
if (delay > 0) {
|
|
49
|
+
await sleep(delay);
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (lastError instanceof CommsRequestError) {
|
|
57
|
+
throw lastError;
|
|
58
|
+
}
|
|
59
|
+
throw new CommsRequestError(lastError?.message ?? 'Request failed');
|
|
60
|
+
}
|
|
61
|
+
async function fetchWithDefaultTransport(url, options, signal) {
|
|
62
|
+
const dispatcher = await getDefaultDispatcher();
|
|
63
|
+
const response = dispatcher
|
|
64
|
+
? await fetch(url, {
|
|
65
|
+
...options,
|
|
66
|
+
signal,
|
|
67
|
+
// @ts-expect-error - dispatcher is valid for Node.js fetch but not in TS types
|
|
68
|
+
dispatcher,
|
|
69
|
+
})
|
|
70
|
+
: await fetch(url, {
|
|
71
|
+
...options,
|
|
72
|
+
signal,
|
|
73
|
+
});
|
|
74
|
+
return convertResponseToCustomFetch(response);
|
|
75
|
+
}
|
|
76
|
+
async function sleep(ms) {
|
|
77
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
+
}
|
|
79
|
+
const timeoutErrorName = 'TimeoutError';
|
|
80
|
+
function createTimeoutError(timeoutMs) {
|
|
81
|
+
const error = new Error(`Request timeout after ${timeoutMs}ms`);
|
|
82
|
+
error.name = timeoutErrorName;
|
|
83
|
+
return error;
|
|
84
|
+
}
|
|
85
|
+
function isNetworkError(error) {
|
|
86
|
+
return (error.name === 'TypeError' ||
|
|
87
|
+
error.name === timeoutErrorName ||
|
|
88
|
+
error.message.toLowerCase().includes('network'));
|
|
89
|
+
}
|
|
90
|
+
function getRetryDelay(retryCount) {
|
|
91
|
+
return retryCount === 1 ? 0 : 500;
|
|
92
|
+
}
|
|
93
|
+
function createTimeoutSignal(timeoutMs, existingSignal) {
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const timeoutId = setTimeout(() => {
|
|
96
|
+
controller.abort(createTimeoutError(timeoutMs));
|
|
97
|
+
}, timeoutMs);
|
|
98
|
+
let abortHandler;
|
|
99
|
+
function clear() {
|
|
100
|
+
clearTimeout(timeoutId);
|
|
101
|
+
if (existingSignal && abortHandler) {
|
|
102
|
+
existingSignal.removeEventListener('abort', abortHandler);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (existingSignal) {
|
|
106
|
+
if (existingSignal.aborted) {
|
|
107
|
+
clearTimeout(timeoutId);
|
|
108
|
+
controller.abort(existingSignal.reason);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
abortHandler = () => {
|
|
112
|
+
clearTimeout(timeoutId);
|
|
113
|
+
controller.abort(existingSignal.reason);
|
|
114
|
+
};
|
|
115
|
+
existingSignal.addEventListener('abort', abortHandler, { once: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { signal: controller.signal, clear };
|
|
119
|
+
}
|
|
120
|
+
function convertResponseToCustomFetch(response) {
|
|
121
|
+
const headers = {};
|
|
122
|
+
response.headers.forEach((value, key) => {
|
|
123
|
+
headers[key] = value;
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
ok: response.ok,
|
|
127
|
+
status: response.status,
|
|
128
|
+
statusText: response.statusText,
|
|
129
|
+
headers,
|
|
130
|
+
text: () => response.clone().text(),
|
|
131
|
+
json: () => response.json(),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { snakeCaseKeys } from '../utils/case-conversion.js';
|
|
2
|
+
import { fetchWithRetry } from './fetch-with-retry.js';
|
|
3
|
+
export function paramsSerializer(params) {
|
|
4
|
+
const qs = new URLSearchParams();
|
|
5
|
+
Object.keys(params).forEach((key) => {
|
|
6
|
+
const value = params[key];
|
|
7
|
+
if (value != null) {
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
qs.append(key, value.join(','));
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
qs.append(key, String(value));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
return qs.toString();
|
|
17
|
+
}
|
|
18
|
+
const defaultHeaders = {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
};
|
|
21
|
+
function getAuthHeader(apiToken) {
|
|
22
|
+
return `Bearer ${apiToken}`;
|
|
23
|
+
}
|
|
24
|
+
function getRequestConfiguration(baseURL, apiToken, requestId) {
|
|
25
|
+
const authHeader = apiToken ? { Authorization: getAuthHeader(apiToken) } : undefined;
|
|
26
|
+
const requestIdHeader = requestId ? { 'X-Request-Id': requestId } : undefined;
|
|
27
|
+
const headers = { ...defaultHeaders, ...authHeader, ...requestIdHeader };
|
|
28
|
+
return { baseURL, headers, timeout: 30000 };
|
|
29
|
+
}
|
|
30
|
+
export async function request(args) {
|
|
31
|
+
const { httpMethod, baseUri, relativePath, apiToken, payload, requestId, customFetch } = args;
|
|
32
|
+
const config = getRequestConfiguration(baseUri, apiToken, requestId);
|
|
33
|
+
const url = new URL(relativePath, config.baseURL).toString();
|
|
34
|
+
const options = {
|
|
35
|
+
method: httpMethod,
|
|
36
|
+
headers: config.headers,
|
|
37
|
+
timeout: config.timeout,
|
|
38
|
+
};
|
|
39
|
+
if (httpMethod === 'GET' && payload) {
|
|
40
|
+
const searchParams = paramsSerializer(snakeCaseKeys(payload));
|
|
41
|
+
const urlWithParams = searchParams ? `${url}?${searchParams}` : url;
|
|
42
|
+
return fetchWithRetry(urlWithParams, options, 3, customFetch);
|
|
43
|
+
}
|
|
44
|
+
if (payload && httpMethod !== 'GET') {
|
|
45
|
+
options.body = JSON.stringify(snakeCaseKeys(payload));
|
|
46
|
+
}
|
|
47
|
+
return fetchWithRetry(url, options, 3, customFetch);
|
|
48
|
+
}
|
|
49
|
+
export function isSuccess(response) {
|
|
50
|
+
return response.status >= 200 && response.status < 300;
|
|
51
|
+
}
|