@doist/todoist-api-typescript 6.0.1 → 6.1.4
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/README.md +105 -5
- package/dist/cjs/authentication.js +59 -63
- package/dist/cjs/rest-client.js +12 -10
- package/dist/cjs/test-utils/mocks.js +2 -45
- package/dist/cjs/test-utils/msw-setup.js +74 -5
- package/dist/cjs/test-utils/obsidian-fetch-adapter.js +53 -0
- package/dist/cjs/todoist-api.js +80 -30
- package/dist/cjs/types/entities.js +4 -4
- package/dist/cjs/types/index.js +1 -0
- package/dist/cjs/utils/fetch-with-retry.js +37 -14
- package/dist/cjs/utils/multipart-upload.js +2 -1
- package/dist/esm/authentication.js +59 -63
- package/dist/esm/rest-client.js +12 -10
- package/dist/esm/test-utils/mocks.js +3 -10
- package/dist/esm/test-utils/msw-setup.js +67 -2
- package/dist/esm/test-utils/obsidian-fetch-adapter.js +50 -0
- package/dist/esm/todoist-api.js +80 -30
- package/dist/esm/types/entities.js +4 -4
- package/dist/esm/types/index.js +1 -0
- package/dist/esm/utils/fetch-with-retry.js +37 -14
- package/dist/esm/utils/multipart-upload.js +2 -1
- package/dist/types/authentication.d.ts +20 -0
- package/dist/types/rest-client.d.ts +2 -1
- package/dist/types/test-utils/mocks.d.ts +0 -1
- package/dist/types/test-utils/msw-setup.d.ts +31 -1
- package/dist/types/test-utils/obsidian-fetch-adapter.d.ts +29 -0
- package/dist/types/todoist-api.d.ts +18 -7
- package/dist/types/types/entities.d.ts +4 -4
- package/dist/types/types/http.d.ts +17 -0
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/sync.d.ts +5 -5
- package/dist/types/utils/fetch-with-retry.d.ts +2 -1
- package/dist/types/utils/multipart-upload.d.ts +2 -0
- package/package.json +4 -3
|
@@ -51,81 +51,76 @@ export function getAuthorizationUrl({ clientId, permissions, state, baseUrl, })
|
|
|
51
51
|
const scope = permissions.join(',');
|
|
52
52
|
return `${getAuthBaseUri(baseUrl)}${ENDPOINT_AUTHORIZATION}?client_id=${clientId}&scope=${scope}&state=${state}`;
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
* Exchanges an authorization code for an access token.
|
|
56
|
-
*
|
|
57
|
-
* @example
|
|
58
|
-
* ```typescript
|
|
59
|
-
* const { accessToken } = await getAuthToken({
|
|
60
|
-
* clientId: 'your-client-id',
|
|
61
|
-
* clientSecret: 'your-client-secret',
|
|
62
|
-
* code: authCode
|
|
63
|
-
* })
|
|
64
|
-
* ```
|
|
65
|
-
*
|
|
66
|
-
* @returns The access token response
|
|
67
|
-
* @throws {@link TodoistRequestError} If the token exchange fails
|
|
68
|
-
*/
|
|
69
|
-
export async function getAuthToken(args, baseUrl) {
|
|
54
|
+
export async function getAuthToken(args, baseUrlOrOptions) {
|
|
70
55
|
var _a;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
if (
|
|
79
|
-
|
|
56
|
+
let baseUrl;
|
|
57
|
+
let customFetch;
|
|
58
|
+
if (typeof baseUrlOrOptions === 'string') {
|
|
59
|
+
// Legacy signature: (args, baseUrl)
|
|
60
|
+
baseUrl = baseUrlOrOptions;
|
|
61
|
+
customFetch = undefined;
|
|
62
|
+
}
|
|
63
|
+
else if (baseUrlOrOptions) {
|
|
64
|
+
// New signature: (args, options)
|
|
65
|
+
baseUrl = baseUrlOrOptions.baseUrl;
|
|
66
|
+
customFetch = baseUrlOrOptions.customFetch;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const response = await request({
|
|
70
|
+
httpMethod: 'POST',
|
|
71
|
+
baseUri: getAuthBaseUri(baseUrl),
|
|
72
|
+
relativePath: ENDPOINT_GET_TOKEN,
|
|
73
|
+
apiToken: undefined,
|
|
74
|
+
payload: args,
|
|
75
|
+
customFetch,
|
|
76
|
+
});
|
|
77
|
+
if (response.status !== 200 || !((_a = response.data) === null || _a === void 0 ? void 0 : _a.accessToken)) {
|
|
78
|
+
throw new TodoistRequestError('Authentication token exchange failed.', response.status, response.data);
|
|
79
|
+
}
|
|
80
|
+
return response.data;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// Re-throw with custom message for authentication failures
|
|
84
|
+
const err = error;
|
|
85
|
+
throw new TodoistRequestError('Authentication token exchange failed.', err.httpStatusCode, err.responseData);
|
|
80
86
|
}
|
|
81
|
-
return response.data;
|
|
82
87
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* @returns True if revocation was successful
|
|
97
|
-
* @see https://todoist.com/api/v1/docs#tag/Authorization/operation/revoke_access_token_api_api_v1_access_tokens_delete
|
|
98
|
-
*/
|
|
99
|
-
export async function revokeAuthToken(args, baseUrl) {
|
|
88
|
+
export async function revokeAuthToken(args, baseUrlOrOptions) {
|
|
89
|
+
let baseUrl;
|
|
90
|
+
let customFetch;
|
|
91
|
+
if (typeof baseUrlOrOptions === 'string') {
|
|
92
|
+
// Legacy signature: (args, baseUrl)
|
|
93
|
+
baseUrl = baseUrlOrOptions;
|
|
94
|
+
customFetch = undefined;
|
|
95
|
+
}
|
|
96
|
+
else if (baseUrlOrOptions) {
|
|
97
|
+
// New signature: (args, options)
|
|
98
|
+
baseUrl = baseUrlOrOptions.baseUrl;
|
|
99
|
+
customFetch = baseUrlOrOptions.customFetch;
|
|
100
|
+
}
|
|
100
101
|
const response = await request({
|
|
101
102
|
httpMethod: 'POST',
|
|
102
103
|
baseUri: getSyncBaseUri(baseUrl),
|
|
103
104
|
relativePath: ENDPOINT_REVOKE_TOKEN,
|
|
104
105
|
apiToken: undefined,
|
|
105
106
|
payload: args,
|
|
107
|
+
customFetch,
|
|
106
108
|
});
|
|
107
109
|
return isSuccess(response);
|
|
108
110
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
* ```
|
|
123
|
-
*
|
|
124
|
-
* @returns True if revocation was successful
|
|
125
|
-
* @see https://datatracker.ietf.org/doc/html/rfc7009
|
|
126
|
-
* @see https://todoist.com/api/v1/docs#tag/Authorization
|
|
127
|
-
*/
|
|
128
|
-
export async function revokeToken(args, baseUrl) {
|
|
111
|
+
export async function revokeToken(args, baseUrlOrOptions) {
|
|
112
|
+
let baseUrl;
|
|
113
|
+
let customFetch;
|
|
114
|
+
if (typeof baseUrlOrOptions === 'string') {
|
|
115
|
+
// Legacy signature: (args, baseUrl)
|
|
116
|
+
baseUrl = baseUrlOrOptions;
|
|
117
|
+
customFetch = undefined;
|
|
118
|
+
}
|
|
119
|
+
else if (baseUrlOrOptions) {
|
|
120
|
+
// New signature: (args, options)
|
|
121
|
+
baseUrl = baseUrlOrOptions.baseUrl;
|
|
122
|
+
customFetch = baseUrlOrOptions.customFetch;
|
|
123
|
+
}
|
|
129
124
|
const { clientId, clientSecret, token } = args;
|
|
130
125
|
// Create Basic Auth header as per RFC 7009
|
|
131
126
|
const basicAuth = createBasicAuthHeader(clientId, clientSecret);
|
|
@@ -146,6 +141,7 @@ export async function revokeToken(args, baseUrl) {
|
|
|
146
141
|
requestId: undefined,
|
|
147
142
|
hasSyncCommands: false,
|
|
148
143
|
customHeaders: customHeaders,
|
|
144
|
+
customFetch,
|
|
149
145
|
});
|
|
150
146
|
return isSuccess(response);
|
|
151
147
|
}
|
package/dist/esm/rest-client.js
CHANGED
|
@@ -58,7 +58,7 @@ export function isSuccess(response) {
|
|
|
58
58
|
return response.status >= 200 && response.status < 300;
|
|
59
59
|
}
|
|
60
60
|
export async function request(args) {
|
|
61
|
-
const { httpMethod, baseUri, relativePath, apiToken, payload, requestId: initialRequestId, hasSyncCommands, customHeaders, } = args;
|
|
61
|
+
const { httpMethod, baseUri, relativePath, apiToken, payload, requestId: initialRequestId, hasSyncCommands, customHeaders, customFetch, } = args;
|
|
62
62
|
// Capture original stack for better error reporting
|
|
63
63
|
const originalStack = new Error();
|
|
64
64
|
try {
|
|
@@ -89,24 +89,26 @@ export async function request(args) {
|
|
|
89
89
|
}
|
|
90
90
|
break;
|
|
91
91
|
case 'POST':
|
|
92
|
-
case 'PUT':
|
|
92
|
+
case 'PUT':
|
|
93
|
+
case 'DELETE': {
|
|
93
94
|
// Convert payload from camelCase to snake_case
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
// Note: While DELETE with body is uncommon, the Todoist API uses it for some endpoints
|
|
96
|
+
if (payload) {
|
|
97
|
+
const convertedPayload = snakeCaseKeys(payload);
|
|
98
|
+
const body = hasSyncCommands
|
|
99
|
+
? JSON.stringify(convertedPayload)
|
|
100
|
+
: JSON.stringify(convertedPayload);
|
|
101
|
+
fetchOptions.body = body;
|
|
102
|
+
}
|
|
99
103
|
break;
|
|
100
104
|
}
|
|
101
|
-
case 'DELETE':
|
|
102
|
-
// DELETE requests don't have a body
|
|
103
|
-
break;
|
|
104
105
|
}
|
|
105
106
|
// Make the request
|
|
106
107
|
const response = await fetchWithRetry({
|
|
107
108
|
url: finalUrl,
|
|
108
109
|
options: fetchOptions,
|
|
109
110
|
retryConfig: config.retry,
|
|
111
|
+
customFetch,
|
|
110
112
|
});
|
|
111
113
|
// Convert snake_case response to camelCase
|
|
112
114
|
const convertedData = camelCaseKeys(response.data);
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
status,
|
|
5
|
-
statusText: status === 200 ? 'OK' : 'Error',
|
|
6
|
-
headers: {},
|
|
7
|
-
data: responseData,
|
|
8
|
-
};
|
|
9
|
-
return jest.spyOn(restClient, 'request').mockResolvedValue(response);
|
|
10
|
-
}
|
|
1
|
+
"use strict";
|
|
2
|
+
// This file is reserved for future test utilities
|
|
3
|
+
// All network mocking is now handled by MSW in msw-setup.ts
|
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
import { setupServer } from 'msw/node';
|
|
2
|
+
import { http, HttpResponse } from 'msw';
|
|
3
|
+
// Request capture storage
|
|
4
|
+
const capturedRequests = [];
|
|
5
|
+
// Helper function to capture requests
|
|
6
|
+
export function captureRequest({ request, body }) {
|
|
7
|
+
const headers = {};
|
|
8
|
+
request.headers.forEach((value, key) => {
|
|
9
|
+
headers[key] = value;
|
|
10
|
+
});
|
|
11
|
+
capturedRequests.push({
|
|
12
|
+
url: request.url,
|
|
13
|
+
method: request.method,
|
|
14
|
+
headers,
|
|
15
|
+
body,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
// Helper function to get the last captured request
|
|
19
|
+
export function getLastRequest() {
|
|
20
|
+
return capturedRequests[capturedRequests.length - 1];
|
|
21
|
+
}
|
|
22
|
+
// Helper function to get all captured requests
|
|
23
|
+
export function getAllRequests() {
|
|
24
|
+
return [...capturedRequests];
|
|
25
|
+
}
|
|
26
|
+
// Helper function to clear captured requests
|
|
27
|
+
export function clearCapturedRequests() {
|
|
28
|
+
capturedRequests.length = 0;
|
|
29
|
+
}
|
|
2
30
|
// Default handlers for common API responses
|
|
3
31
|
export const handlers = [
|
|
4
32
|
// Default handlers can be added here for common endpoints
|
|
@@ -6,17 +34,54 @@ export const handlers = [
|
|
|
6
34
|
];
|
|
7
35
|
// Create MSW server instance
|
|
8
36
|
export const server = setupServer(...handlers);
|
|
37
|
+
// Helper to create a resolver function for MSW handlers
|
|
38
|
+
function createResolver(data, status, headers) {
|
|
39
|
+
return async function resolver({ request }) {
|
|
40
|
+
let body = undefined;
|
|
41
|
+
if (request.method !== 'GET') {
|
|
42
|
+
try {
|
|
43
|
+
body = await request.json();
|
|
44
|
+
}
|
|
45
|
+
catch (_a) {
|
|
46
|
+
// Body might not be JSON
|
|
47
|
+
try {
|
|
48
|
+
body = await request.text();
|
|
49
|
+
}
|
|
50
|
+
catch (_b) {
|
|
51
|
+
// Body might be FormData or not parseable
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
captureRequest({ request, body });
|
|
56
|
+
return HttpResponse.json(data, {
|
|
57
|
+
status,
|
|
58
|
+
headers,
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Helper to mock a successful API response
|
|
63
|
+
export function mockApiResponse({ endpoint, data, options = {}, }) {
|
|
64
|
+
const { status = 200, method = 'GET', headers = {} } = options;
|
|
65
|
+
const resolver = createResolver(data, status, headers);
|
|
66
|
+
const httpMethod = method.toLowerCase();
|
|
67
|
+
server.use(http[httpMethod](endpoint, resolver));
|
|
68
|
+
}
|
|
69
|
+
// Helper to mock an error response
|
|
70
|
+
export function mockApiError({ endpoint, data, status, options = {}, }) {
|
|
71
|
+
mockApiResponse({ endpoint, data, options: Object.assign(Object.assign({}, options), { status }) });
|
|
72
|
+
}
|
|
9
73
|
// Setup MSW for tests
|
|
10
74
|
beforeAll(() => {
|
|
11
75
|
server.listen({
|
|
12
|
-
onUnhandledRequest: '
|
|
76
|
+
onUnhandledRequest: 'error', // Throw errors for unhandled requests to catch unexpected fetch calls
|
|
13
77
|
});
|
|
14
78
|
});
|
|
15
79
|
afterEach(() => {
|
|
16
80
|
server.resetHandlers(); // Reset handlers between tests
|
|
81
|
+
clearCapturedRequests(); // Clear captured requests between tests
|
|
17
82
|
});
|
|
18
83
|
afterAll(() => {
|
|
19
84
|
server.close(); // Clean up after all tests
|
|
20
85
|
});
|
|
21
86
|
// Export MSW utilities for use in tests
|
|
22
|
-
export { http, HttpResponse }
|
|
87
|
+
export { http, HttpResponse };
|
|
@@ -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 Todoist 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 TodoistApi('your-token', {
|
|
20
|
+
* customFetch: createObsidianFetchAdapter(requestUrl)
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @param requestUrl - The Obsidian requestUrl function
|
|
25
|
+
* @returns A CustomFetch function compatible with the Todoist 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 === null || options === void 0 ? void 0 : options.method) || 'GET',
|
|
33
|
+
headers: options === null || options === void 0 ? void 0 : options.headers,
|
|
34
|
+
body: options === null || options === void 0 ? void 0 : 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
|
+
}
|