@doist/todoist-api-typescript 6.0.1 → 6.1.5

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.
Files changed (34) hide show
  1. package/README.md +105 -5
  2. package/dist/cjs/authentication.js +59 -63
  3. package/dist/cjs/rest-client.js +18 -11
  4. package/dist/cjs/test-utils/mocks.js +2 -45
  5. package/dist/cjs/test-utils/msw-setup.js +74 -5
  6. package/dist/cjs/test-utils/obsidian-fetch-adapter.js +53 -0
  7. package/dist/cjs/todoist-api.js +80 -30
  8. package/dist/cjs/types/entities.js +4 -4
  9. package/dist/cjs/types/index.js +1 -0
  10. package/dist/cjs/utils/fetch-with-retry.js +37 -14
  11. package/dist/cjs/utils/multipart-upload.js +2 -1
  12. package/dist/esm/authentication.js +59 -63
  13. package/dist/esm/rest-client.js +18 -11
  14. package/dist/esm/test-utils/mocks.js +3 -10
  15. package/dist/esm/test-utils/msw-setup.js +67 -2
  16. package/dist/esm/test-utils/obsidian-fetch-adapter.js +50 -0
  17. package/dist/esm/todoist-api.js +80 -30
  18. package/dist/esm/types/entities.js +4 -4
  19. package/dist/esm/types/index.js +1 -0
  20. package/dist/esm/utils/fetch-with-retry.js +37 -14
  21. package/dist/esm/utils/multipart-upload.js +2 -1
  22. package/dist/types/authentication.d.ts +26 -3
  23. package/dist/types/rest-client.d.ts +2 -1
  24. package/dist/types/test-utils/mocks.d.ts +0 -1
  25. package/dist/types/test-utils/msw-setup.d.ts +31 -1
  26. package/dist/types/test-utils/obsidian-fetch-adapter.d.ts +29 -0
  27. package/dist/types/todoist-api.d.ts +19 -7
  28. package/dist/types/types/entities.d.ts +4 -4
  29. package/dist/types/types/http.d.ts +17 -0
  30. package/dist/types/types/index.d.ts +1 -0
  31. package/dist/types/types/sync.d.ts +5 -5
  32. package/dist/types/utils/fetch-with-retry.d.ts +2 -1
  33. package/dist/types/utils/multipart-upload.d.ts +2 -0
  34. package/package.json +8 -7
@@ -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
- const response = await request({
72
- httpMethod: 'POST',
73
- baseUri: getAuthBaseUri(baseUrl),
74
- relativePath: ENDPOINT_GET_TOKEN,
75
- apiToken: undefined,
76
- payload: args,
77
- });
78
- if (response.status !== 200 || !((_a = response.data) === null || _a === void 0 ? void 0 : _a.accessToken)) {
79
- throw new TodoistRequestError('Authentication token exchange failed.', response.status, response.data);
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
- * Revokes an access token, making it invalid for future use.
85
- *
86
- * @example
87
- * ```typescript
88
- * await revokeAuthToken({
89
- * clientId: 'your-client-id',
90
- * clientSecret: 'your-client-secret',
91
- * accessToken: token
92
- * })
93
- * ```
94
- *
95
- * @deprecated Use {@link revokeToken} instead. This function uses a legacy endpoint that will be removed in a future version. The new function uses the RFC 7009 compliant endpoint.
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
- * Revokes a token using the RFC 7009 OAuth 2.0 Token Revocation standard.
111
- *
112
- * This function uses HTTP Basic Authentication with client credentials and follows
113
- * the RFC 7009 specification for token revocation.
114
- *
115
- * @example
116
- * ```typescript
117
- * await revokeToken({
118
- * clientId: 'your-client-id',
119
- * clientSecret: 'your-client-secret',
120
- * token: 'access-token-to-revoke'
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
  }
@@ -12,9 +12,14 @@ export function paramsSerializer(params) {
12
12
  if (Array.isArray(value)) {
13
13
  qs.append(key, value.join(','));
14
14
  }
15
- else {
15
+ else if (typeof value === 'string' ||
16
+ typeof value === 'number' ||
17
+ typeof value === 'boolean') {
16
18
  qs.append(key, String(value));
17
19
  }
20
+ else {
21
+ qs.append(key, JSON.stringify(value));
22
+ }
18
23
  }
19
24
  });
20
25
  return qs.toString();
@@ -58,7 +63,7 @@ export function isSuccess(response) {
58
63
  return response.status >= 200 && response.status < 300;
59
64
  }
60
65
  export async function request(args) {
61
- const { httpMethod, baseUri, relativePath, apiToken, payload, requestId: initialRequestId, hasSyncCommands, customHeaders, } = args;
66
+ const { httpMethod, baseUri, relativePath, apiToken, payload, requestId: initialRequestId, hasSyncCommands, customHeaders, customFetch, } = args;
62
67
  // Capture original stack for better error reporting
63
68
  const originalStack = new Error();
64
69
  try {
@@ -89,24 +94,26 @@ export async function request(args) {
89
94
  }
90
95
  break;
91
96
  case 'POST':
92
- case 'PUT': {
97
+ case 'PUT':
98
+ case 'DELETE': {
93
99
  // Convert payload from camelCase to snake_case
94
- const convertedPayload = payload ? snakeCaseKeys(payload) : payload;
95
- const body = hasSyncCommands
96
- ? JSON.stringify(convertedPayload)
97
- : JSON.stringify(convertedPayload);
98
- fetchOptions.body = body;
100
+ // Note: While DELETE with body is uncommon, the Todoist API uses it for some endpoints
101
+ if (payload) {
102
+ const convertedPayload = snakeCaseKeys(payload);
103
+ const body = hasSyncCommands
104
+ ? JSON.stringify(convertedPayload)
105
+ : JSON.stringify(convertedPayload);
106
+ fetchOptions.body = body;
107
+ }
99
108
  break;
100
109
  }
101
- case 'DELETE':
102
- // DELETE requests don't have a body
103
- break;
104
110
  }
105
111
  // Make the request
106
112
  const response = await fetchWithRetry({
107
113
  url: finalUrl,
108
114
  options: fetchOptions,
109
115
  retryConfig: config.retry,
116
+ customFetch,
110
117
  });
111
118
  // Convert snake_case response to camelCase
112
119
  const convertedData = camelCaseKeys(response.data);
@@ -1,10 +1,3 @@
1
- import * as restClient from '../rest-client.js';
2
- export function setupRestClientMock(responseData, status = 200) {
3
- const response = {
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: 'warn', // Log warnings for unhandled requests during development
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 } from 'msw';
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
+ }