From c3acd1bc1a0898492c6784a07dae43c620adee3c Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Fri, 7 Nov 2025 12:49:44 +0000 Subject: [PATCH 1/3] feat: add custom HTTP client support for cross-platform compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for custom fetch implementations to enable usage in restrictive environments like Obsidian plugins, browser extensions, Electron apps, React Native, and enterprise environments with specific networking requirements. Key features: - CustomFetch and CustomFetchResponse types for HTTP client abstraction - Backward-compatible TodoistApi constructor with options object pattern - OAuth functions (getAuthToken, revokeAuthToken, revokeToken) support custom fetch - Complete HTTP layer integration with retry logic and error handling preserved - File upload support with custom fetch implementations - All existing transforms (snake_case ↔ camelCase) work automatically - Comprehensive documentation and usage examples Resolves #381 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 72 ++++++++++++++++++++++ src/authentication.ts | 84 +++++++++++++++++++++++++- src/custom-fetch-simple.test.ts | 87 +++++++++++++++++++++++++++ src/rest-client.ts | 5 +- src/todoist-api.ts | 102 +++++++++++++++++++++++++++++++- src/types/http.ts | 20 +++++++ src/types/index.ts | 1 + src/utils/fetch-with-retry.ts | 65 ++++++++++++++------ src/utils/multipart-upload.ts | 15 ++++- 9 files changed, 426 insertions(+), 25 deletions(-) create mode 100644 src/custom-fetch-simple.test.ts diff --git a/README.md b/README.md index ea480af9..3d3ee9e7 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,78 @@ Key changes in v1 include: - Object renames (e.g., items → tasks, notes → comments) - URL renames and endpoint signature changes +## Custom HTTP Clients + +The Todoist API client supports custom HTTP implementations to enable usage in environments with specific networking requirements, such as: + +- **Obsidian plugins** - Desktop app with strict CORS policies +- **Browser extensions** - Custom HTTP APIs with different security models +- **Electron apps** - Requests routed through IPC layer +- **React Native** - Different networking stack +- **Enterprise environments** - Proxy configuration, custom headers, or certificate handling + +### Basic Usage + +```typescript +import { TodoistApi } from '@doist/todoist-api-typescript' + +// Using the new options-based constructor +const api = new TodoistApi('YOURTOKEN', { + baseUrl: 'https://custom-api.example.com', // optional + customFetch: myCustomFetch, // your custom fetch implementation +}) + +// Legacy constructor (deprecated but supported) +const apiLegacy = new TodoistApi('YOURTOKEN', 'https://custom-api.example.com') +``` + +### Custom Fetch Interface + +Your custom fetch function must implement this interface: + +```typescript +type CustomFetch = ( + url: string, + options?: RequestInit & { timeout?: number }, +) => Promise + +type CustomFetchResponse = { + ok: boolean + status: number + statusText: string + headers: Record + text(): Promise + json(): Promise +} +``` + +### OAuth with Custom Fetch + +OAuth authentication functions (`getAuthToken`, `revokeAuthToken`, `revokeToken`) support custom fetch through an options object: + +```typescript +// New options-based usage +const { accessToken } = await getAuthToken(args, { + baseUrl: 'https://custom-auth.example.com', + customFetch: myCustomFetch, +}) + +await revokeToken(args, { + customFetch: myCustomFetch, +}) + +// Legacy usage (deprecated) +const { accessToken } = await getAuthToken(args, baseUrl) +``` + +### Important Notes + +- All existing transforms (snake_case ↔ camelCase) work automatically with custom fetch +- Retry logic and error handling are preserved +- File uploads work with custom fetch implementations +- The custom fetch function should handle FormData for multipart uploads +- Timeout parameter is optional and up to your custom implementation + ## Development and Testing Instead of having an example app in the repository to assist development and testing, we have included [ts-node](https://github.com/TypeStrong/ts-node) as a dev dependency. This allows us to have a scratch file locally that can import and utilize the API while developing or reviewing pull requests without having to manage a separate app project. diff --git a/src/authentication.ts b/src/authentication.ts index 9b6a748b..0b6269e9 100644 --- a/src/authentication.ts +++ b/src/authentication.ts @@ -1,6 +1,7 @@ import { request, isSuccess } from './rest-client' import { v4 as uuid } from 'uuid' import { TodoistRequestError } from './types' +import { CustomFetch } from './types/http' import { getAuthBaseUri, getSyncBaseUri, @@ -10,6 +11,14 @@ import { ENDPOINT_REVOKE, } from './consts/endpoints' +/** + * Options for authentication functions + */ +export type AuthOptions = { + baseUrl?: string + customFetch?: CustomFetch +} + /** * Permission scopes that can be requested during OAuth2 authorization. * @see {@link https://todoist.com/api/v1/docs#tag/Authorization} @@ -140,10 +149,35 @@ export function getAuthorizationUrl({ * @returns The access token response * @throws {@link TodoistRequestError} If the token exchange fails */ +// Function overloads for backward compatibility +/** + * @deprecated Use options object instead: getAuthToken(args, { baseUrl, customFetch }) + */ export async function getAuthToken( args: AuthTokenRequestArgs, baseUrl?: string, +): Promise +export async function getAuthToken( + args: AuthTokenRequestArgs, + options?: AuthOptions, +): Promise +export async function getAuthToken( + args: AuthTokenRequestArgs, + baseUrlOrOptions?: string | AuthOptions, ): Promise { + let baseUrl: string | undefined + let customFetch: CustomFetch | undefined + + if (typeof baseUrlOrOptions === 'string') { + // Legacy signature: (args, baseUrl) + baseUrl = baseUrlOrOptions + customFetch = undefined + } else if (baseUrlOrOptions) { + // New signature: (args, options) + baseUrl = baseUrlOrOptions.baseUrl + customFetch = baseUrlOrOptions.customFetch + } + try { const response = await request({ httpMethod: 'POST', @@ -151,6 +185,7 @@ export async function getAuthToken( relativePath: ENDPOINT_GET_TOKEN, apiToken: undefined, payload: args, + customFetch, }) if (response.status !== 200 || !response.data?.accessToken) { @@ -189,16 +224,41 @@ export async function getAuthToken( * @returns True if revocation was successful * @see https://todoist.com/api/v1/docs#tag/Authorization/operation/revoke_access_token_api_api_v1_access_tokens_delete */ +// Function overloads for backward compatibility +/** + * @deprecated Use options object instead: revokeAuthToken(args, { baseUrl, customFetch }) + */ export async function revokeAuthToken( args: RevokeAuthTokenRequestArgs, baseUrl?: string, +): Promise +export async function revokeAuthToken( + args: RevokeAuthTokenRequestArgs, + options?: AuthOptions, +): Promise +export async function revokeAuthToken( + args: RevokeAuthTokenRequestArgs, + baseUrlOrOptions?: string | AuthOptions, ): Promise { + let baseUrl: string | undefined + let customFetch: CustomFetch | undefined + + if (typeof baseUrlOrOptions === 'string') { + // Legacy signature: (args, baseUrl) + baseUrl = baseUrlOrOptions + customFetch = undefined + } else if (baseUrlOrOptions) { + // New signature: (args, options) + baseUrl = baseUrlOrOptions.baseUrl + customFetch = baseUrlOrOptions.customFetch + } const response = await request({ httpMethod: 'POST', baseUri: getSyncBaseUri(baseUrl), relativePath: ENDPOINT_REVOKE_TOKEN, apiToken: undefined, payload: args, + customFetch, }) return isSuccess(response) @@ -223,10 +283,31 @@ export async function revokeAuthToken( * @see https://datatracker.ietf.org/doc/html/rfc7009 * @see https://todoist.com/api/v1/docs#tag/Authorization */ +// Function overloads for backward compatibility +/** + * @deprecated Use options object instead: revokeToken(args, { baseUrl, customFetch }) + */ +export async function revokeToken(args: RevokeTokenRequestArgs, baseUrl?: string): Promise export async function revokeToken( args: RevokeTokenRequestArgs, - baseUrl?: string, + options?: AuthOptions, +): Promise +export async function revokeToken( + args: RevokeTokenRequestArgs, + baseUrlOrOptions?: string | AuthOptions, ): Promise { + let baseUrl: string | undefined + let customFetch: CustomFetch | undefined + + if (typeof baseUrlOrOptions === 'string') { + // Legacy signature: (args, baseUrl) + baseUrl = baseUrlOrOptions + customFetch = undefined + } else if (baseUrlOrOptions) { + // New signature: (args, options) + baseUrl = baseUrlOrOptions.baseUrl + customFetch = baseUrlOrOptions.customFetch + } const { clientId, clientSecret, token } = args // Create Basic Auth header as per RFC 7009 @@ -250,6 +331,7 @@ export async function revokeToken( requestId: undefined, hasSyncCommands: false, customHeaders: customHeaders, + customFetch, }) return isSuccess(response) diff --git a/src/custom-fetch-simple.test.ts b/src/custom-fetch-simple.test.ts new file mode 100644 index 00000000..30ff7a42 --- /dev/null +++ b/src/custom-fetch-simple.test.ts @@ -0,0 +1,87 @@ +import { TodoistApi } from './todoist-api' +import { CustomFetch, CustomFetchResponse } from './types/http' + +// Mock fetch globally +const mockFetch = jest.fn() +global.fetch = mockFetch as unknown as typeof fetch + +const DEFAULT_AUTH_TOKEN = 'test-auth-token' + +describe('Custom Fetch Core Functionality', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Constructor Options', () => { + it('should accept customFetch in options', () => { + const mockCustomFetch: CustomFetch = jest.fn() + const api = new TodoistApi(DEFAULT_AUTH_TOKEN, { + customFetch: mockCustomFetch, + }) + expect(api).toBeInstanceOf(TodoistApi) + }) + + it('should show deprecation warning for old constructor', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() + const api = new TodoistApi(DEFAULT_AUTH_TOKEN, 'https://custom.api.com') + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('deprecated')) + expect(api).toBeInstanceOf(TodoistApi) + consoleSpy.mockRestore() + }) + }) + + describe('Custom Fetch Usage', () => { + it('should call custom fetch when provided', async () => { + const mockCustomFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + text: () => Promise.resolve('{"id":"123"}'), + json: () => Promise.resolve({ id: '123' }), + } as CustomFetchResponse) + + const api = new TodoistApi(DEFAULT_AUTH_TOKEN, { + customFetch: mockCustomFetch, + }) + + try { + await api.getUser() + } catch (error) { + // Expected to fail validation, but custom fetch should be called + } + + expect(mockCustomFetch).toHaveBeenCalledWith( + 'https://api.todoist.com/api/v1/user', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: `Bearer ${DEFAULT_AUTH_TOKEN}`, + }), + }), + ) + }) + + it('should use native fetch when no custom fetch provided', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['content-type', 'application/json']]), + text: jest.fn().mockResolvedValue('{"id":"123"}'), + json: jest.fn().mockResolvedValue({ id: '123' }), + }) + + const api = new TodoistApi(DEFAULT_AUTH_TOKEN) + + try { + await api.getUser() + } catch (error) { + // Expected to fail validation, but native fetch should be called + } + + expect(mockFetch).toHaveBeenCalled() + }) + }) +}) diff --git a/src/rest-client.ts b/src/rest-client.ts index 6b932014..ca893f70 100644 --- a/src/rest-client.ts +++ b/src/rest-client.ts @@ -1,5 +1,5 @@ import { TodoistRequestError } from './types/errors' -import { HttpMethod, HttpResponse, isNetworkError, isHttpError } from './types/http' +import { HttpMethod, HttpResponse, isNetworkError, isHttpError, CustomFetch } from './types/http' import { v4 as uuidv4 } from 'uuid' import { API_BASE_URI } from './consts/endpoints' import { camelCaseKeys, snakeCaseKeys } from './utils/case-conversion' @@ -33,6 +33,7 @@ type RequestArgs = { requestId?: string hasSyncCommands?: boolean customHeaders?: Record + customFetch?: CustomFetch } export function paramsSerializer(params: Record) { @@ -116,6 +117,7 @@ export async function request(args: RequestArgs): Promise> { requestId: initialRequestId, hasSyncCommands, customHeaders, + customFetch, } = args // Capture original stack for better error reporting @@ -174,6 +176,7 @@ export async function request(args: RequestArgs): Promise> { url: finalUrl, options: fetchOptions, retryConfig: config.retry, + customFetch, }) // Convert snake_case response to camelCase diff --git a/src/todoist-api.ts b/src/todoist-api.ts index 4ec06aa4..5cf71b88 100644 --- a/src/todoist-api.ts +++ b/src/todoist-api.ts @@ -67,6 +67,7 @@ import { AllWorkspaceInvitationsResponse, WorkspaceLogoResponse, } from './types/requests' +import { CustomFetch } from './types/http' import { request, isSuccess } from './rest-client' import { getSyncBaseUri, @@ -170,22 +171,59 @@ function generatePath(...segments: string[]): string { * For more information about the Todoist API v1, see the [official documentation](https://todoist.com/api/v1). * If you're migrating from v9, please refer to the [migration guide](https://todoist.com/api/v1/docs#tag/Migrating-from-v9). */ + +/** + * Configuration options for the TodoistApi constructor + */ +export type TodoistApiOptions = { + /** + * Optional custom API base URL. If not provided, defaults to Todoist's standard API endpoint + */ + baseUrl?: string + /** + * Optional custom fetch function for alternative HTTP clients (e.g., Obsidian's requestUrl, React Native fetch) + */ + customFetch?: CustomFetch +} + export class TodoistApi { private authToken: string private syncApiBase: string + private customFetch?: CustomFetch + // Constructor overloads for backward compatibility + /** + * @deprecated Use options object instead: new TodoistApi(token, { baseUrl, customFetch }) + */ + constructor(authToken: string, baseUrl?: string) + constructor(authToken: string, options?: TodoistApiOptions) constructor( /** * Your Todoist API token. */ authToken: string, /** - * Optional custom API base URL. If not provided, defaults to Todoist's standard API endpoint + * Optional custom API base URL or options object */ - baseUrl?: string, + baseUrlOrOptions?: string | TodoistApiOptions, ) { this.authToken = authToken - this.syncApiBase = getSyncBaseUri(baseUrl) + + // Handle backward compatibility + if (typeof baseUrlOrOptions === 'string') { + // Legacy constructor: (authToken, baseUrl) + // eslint-disable-next-line no-console + console.warn( + 'TodoistApi constructor with baseUrl as second parameter is deprecated. Use options object instead: new TodoistApi(token, { baseUrl, customFetch })', + ) + this.syncApiBase = getSyncBaseUri(baseUrlOrOptions) + this.customFetch = undefined + } else { + // New constructor: (authToken, options) + const options: TodoistApiOptions = (baseUrlOrOptions as TodoistApiOptions) || {} + this.syncApiBase = getSyncBaseUri(options.baseUrl) + this.customFetch = options.customFetch + } } /** @@ -199,6 +237,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_USER, apiToken: this.authToken, + customFetch: this.customFetch, }) return validateCurrentUser(response.data) @@ -217,6 +256,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_TASKS, id), apiToken: this.authToken, + customFetch: this.customFetch, }) return validateTask(response.data) @@ -236,6 +276,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_TASKS, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -259,6 +300,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_TASKS_FILTER, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -284,6 +326,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_TASKS_COMPLETED_BY_COMPLETION_DATE, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -309,6 +352,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_TASKS_COMPLETED_BY_DUE_DATE, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -332,6 +376,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_TASKS_COMPLETED_SEARCH, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -354,6 +399,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_TASKS, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, requestId: requestId, }) @@ -373,6 +419,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_SYNC_QUICK_ADD, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -394,6 +441,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_TASKS, id), apiToken: this.authToken, + customFetch: this.customFetch, payload: args, requestId: requestId, }) @@ -435,6 +483,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_SYNC, apiToken: this.authToken, + customFetch: this.customFetch, payload: syncRequest, requestId: requestId, hasSyncCommands: true, @@ -475,6 +524,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_TASKS, id, ENDPOINT_REST_TASK_MOVE), apiToken: this.authToken, + customFetch: this.customFetch, payload: { ...(args.projectId && { project_id: args.projectId }), ...(args.sectionId && { section_id: args.sectionId }), @@ -500,6 +550,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_TASKS, id, ENDPOINT_REST_TASK_CLOSE), apiToken: this.authToken, + customFetch: this.customFetch, requestId: requestId, }) return isSuccess(response) @@ -519,6 +570,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_TASKS, id, ENDPOINT_REST_TASK_REOPEN), apiToken: this.authToken, + customFetch: this.customFetch, requestId: requestId, }) return isSuccess(response) @@ -538,6 +590,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_TASKS, id), apiToken: this.authToken, + customFetch: this.customFetch, requestId: requestId, }) return isSuccess(response) @@ -556,6 +609,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_PROJECTS, id), apiToken: this.authToken, + customFetch: this.customFetch, }) return validateProject(response.data) @@ -575,6 +629,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_PROJECTS, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -600,6 +655,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_PROJECTS_ARCHIVED, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -625,6 +681,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_PROJECTS, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, requestId: requestId, }) @@ -651,6 +708,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_PROJECTS, id), apiToken: this.authToken, + customFetch: this.customFetch, payload: args, requestId: requestId, }) @@ -672,6 +730,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_PROJECTS, id), apiToken: this.authToken, + customFetch: this.customFetch, requestId: requestId, }) return isSuccess(response) @@ -694,6 +753,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_PROJECTS, id, PROJECT_ARCHIVE), apiToken: this.authToken, + customFetch: this.customFetch, requestId: requestId, }) return validateProject(response.data) @@ -716,6 +776,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_PROJECTS, id, PROJECT_UNARCHIVE), apiToken: this.authToken, + customFetch: this.customFetch, requestId: requestId, }) return validateProject(response.data) @@ -744,6 +805,7 @@ export class TodoistApi { ENDPOINT_REST_PROJECT_COLLABORATORS, ), apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -767,6 +829,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_SECTIONS, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -789,6 +852,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_SECTIONS, id), apiToken: this.authToken, + customFetch: this.customFetch, }) return validateSection(response.data) @@ -807,6 +871,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_SECTIONS, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, requestId: requestId, }) @@ -829,6 +894,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_SECTIONS, id), apiToken: this.authToken, + customFetch: this.customFetch, payload: args, requestId: requestId, }) @@ -849,6 +915,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_SECTIONS, id), apiToken: this.authToken, + customFetch: this.customFetch, requestId: requestId, }) return isSuccess(response) @@ -867,6 +934,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_LABELS, id), apiToken: this.authToken, + customFetch: this.customFetch, }) return validateLabel(response.data) @@ -886,6 +954,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_LABELS, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -908,6 +977,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_LABELS, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, requestId: requestId, }) @@ -930,6 +1000,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_LABELS, id), apiToken: this.authToken, + customFetch: this.customFetch, payload: args, requestId: requestId, }) @@ -950,6 +1021,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_LABELS, id), apiToken: this.authToken, + customFetch: this.customFetch, requestId: requestId, }) return isSuccess(response) @@ -969,6 +1041,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_LABELS_SHARED, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -987,6 +1060,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_LABELS_SHARED_RENAME, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -1005,6 +1079,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_LABELS_SHARED_REMOVE, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -1027,6 +1102,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_COMMENTS, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, }) @@ -1049,6 +1125,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_COMMENTS, id), apiToken: this.authToken, + customFetch: this.customFetch, }) return validateComment(response.data) @@ -1067,6 +1144,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_COMMENTS, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, requestId: requestId, }) @@ -1089,6 +1167,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_COMMENTS, id), apiToken: this.authToken, + customFetch: this.customFetch, payload: args, requestId: requestId, }) @@ -1109,6 +1188,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_COMMENTS, id), apiToken: this.authToken, + customFetch: this.customFetch, requestId: requestId, }) return isSuccess(response) @@ -1124,6 +1204,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_PRODUCTIVITY, apiToken: this.authToken, + customFetch: this.customFetch, }) return validateProductivityStats(response.data) } @@ -1150,6 +1231,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_ACTIVITIES, apiToken: this.authToken, + customFetch: this.customFetch, payload: processedArgs as Record, }) @@ -1219,6 +1301,7 @@ export class TodoistApi { fileName: args.fileName, additionalFields: additionalFields, requestId: requestId, + customFetch: this.customFetch, }) return validateAttachment(data) @@ -1244,6 +1327,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_UPLOADS, apiToken: this.authToken, + customFetch: this.customFetch, payload: args, requestId: requestId, }) @@ -1268,6 +1352,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_WORKSPACE_INVITATIONS, apiToken: this.authToken, + customFetch: this.customFetch, payload: { workspace_id: args.workspaceId }, requestId: requestId, }) @@ -1295,6 +1380,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_WORKSPACE_INVITATIONS_ALL, apiToken: this.authToken, + customFetch: this.customFetch, payload: queryParams, requestId: requestId, }) @@ -1318,6 +1404,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_WORKSPACE_INVITATIONS_DELETE, apiToken: this.authToken, + customFetch: this.customFetch, payload: { workspace_id: args.workspaceId, user_email: args.userEmail, @@ -1344,6 +1431,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: getWorkspaceInvitationAcceptEndpoint(args.inviteCode), apiToken: this.authToken, + customFetch: this.customFetch, requestId: requestId, }) @@ -1366,6 +1454,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: getWorkspaceInvitationRejectEndpoint(args.inviteCode), apiToken: this.authToken, + customFetch: this.customFetch, requestId: requestId, }) @@ -1385,6 +1474,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_WORKSPACE_JOIN, apiToken: this.authToken, + customFetch: this.customFetch, payload: { invite_code: args.inviteCode, workspace_id: args.workspaceId, @@ -1419,6 +1509,7 @@ export class TodoistApi { delete: true, }, requestId: requestId, + customFetch: this.customFetch, }) return data } @@ -1444,6 +1535,7 @@ export class TodoistApi { fileName: args.fileName, additionalFields: additionalFields, requestId: requestId, + customFetch: this.customFetch, }) return data @@ -1465,6 +1557,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_WORKSPACE_PLAN_DETAILS, apiToken: this.authToken, + customFetch: this.customFetch, payload: { workspace_id: args.workspaceId }, requestId: requestId, }) @@ -1503,6 +1596,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: ENDPOINT_WORKSPACE_USERS, apiToken: this.authToken, + customFetch: this.customFetch, payload: queryParams, requestId: requestId, }) @@ -1538,6 +1632,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: getWorkspaceActiveProjectsEndpoint(args.workspaceId), apiToken: this.authToken, + customFetch: this.customFetch, payload: queryParams, requestId: requestId, }) @@ -1579,6 +1674,7 @@ export class TodoistApi { baseUri: this.syncApiBase, relativePath: getWorkspaceArchivedProjectsEndpoint(args.workspaceId), apiToken: this.authToken, + customFetch: this.customFetch, payload: queryParams, requestId: requestId, }) diff --git a/src/types/http.ts b/src/types/http.ts index 93aaf261..ca183691 100644 --- a/src/types/http.ts +++ b/src/types/http.ts @@ -87,3 +87,23 @@ export function isNetworkError(error: Error): error is NetworkError { export function isHttpError(error: Error): error is HttpError { return 'status' in error && typeof (error as HttpError).status === 'number' } + +/** + * Custom fetch response interface that custom HTTP clients must implement + */ +export type CustomFetchResponse = { + ok: boolean + status: number + statusText: string + headers: Record + text(): Promise + json(): Promise +} + +/** + * Custom fetch function type for alternative HTTP clients + */ +export type CustomFetch = ( + url: string, + options?: RequestInit & { timeout?: number }, +) => Promise diff --git a/src/types/index.ts b/src/types/index.ts index 0bc5d4d0..166027fb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ export * from './entities' export * from './errors' export * from './requests' +export * from './http' diff --git a/src/utils/fetch-with-retry.ts b/src/utils/fetch-with-retry.ts index c9f7ec68..e268627b 100644 --- a/src/utils/fetch-with-retry.ts +++ b/src/utils/fetch-with-retry.ts @@ -1,4 +1,4 @@ -import type { HttpResponse, RetryConfig } from '../types/http' +import type { HttpResponse, RetryConfig, CustomFetch, CustomFetchResponse } from '../types/http' import { isNetworkError } from '../types/http' /** @@ -61,6 +61,23 @@ function createTimeoutSignal(timeoutMs: number, existingSignal?: AbortSignal): A return controller.signal } +/** + * Converts native fetch Response to CustomFetchResponse for consistency + */ +function convertResponseToCustomFetch(response: Response): CustomFetchResponse { + // Clone the response so we can read it multiple times (if clone method exists) + const clonedResponse = response.clone ? response.clone() : response + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: headersToObject(response.headers), + text: () => clonedResponse.text(), + json: () => response.json(), + } +} + /** * Performs a fetch request with retry logic and timeout support */ @@ -68,8 +85,9 @@ export async function fetchWithRetry(args: { url: string options?: RequestInit & { timeout?: number } retryConfig?: Partial + customFetch?: CustomFetch }): Promise> { - const { url, options = {}, retryConfig = {} } = args + const { url, options = {}, retryConfig = {}, customFetch } = args const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig } const { timeout, signal: userSignal, ...fetchOptions } = options @@ -83,14 +101,25 @@ export async function fetchWithRetry(args: { requestSignal = createTimeoutSignal(timeout, requestSignal) } - const response = await fetch(url, { - ...fetchOptions, - signal: requestSignal, - }) + // Use custom fetch or native fetch + let fetchResponse: CustomFetchResponse + if (customFetch) { + fetchResponse = await customFetch(url, { + ...fetchOptions, + signal: requestSignal, + timeout, + }) + } else { + const nativeResponse = await fetch(url, { + ...fetchOptions, + signal: requestSignal, + }) + fetchResponse = convertResponseToCustomFetch(nativeResponse) + } // Check if the response is successful - if (!response.ok) { - const errorMessage = `HTTP ${response.status}: ${response.statusText}` + if (!fetchResponse.ok) { + const errorMessage = `HTTP ${fetchResponse.status}: ${fetchResponse.statusText}` const error = new Error(errorMessage) as Error & { status: number statusText: string @@ -98,18 +127,18 @@ export async function fetchWithRetry(args: { data?: unknown } - error.status = response.status - error.statusText = response.statusText + error.status = fetchResponse.status + error.statusText = fetchResponse.statusText error.response = { data: undefined, // Will be set below if we can parse the response - status: response.status, - statusText: response.statusText, - headers: headersToObject(response.headers), + status: fetchResponse.status, + statusText: fetchResponse.statusText, + headers: fetchResponse.headers, } // Try to get response body for error details try { - const responseText = await response.text() + const responseText = await fetchResponse.text() let responseData: unknown try { responseData = responseText ? JSON.parse(responseText) : undefined @@ -126,7 +155,7 @@ export async function fetchWithRetry(args: { } // Parse response - const responseText = await response.text() + const responseText = await fetchResponse.text() let data: T try { data = responseText ? (JSON.parse(responseText) as T) : (undefined as T) @@ -137,9 +166,9 @@ export async function fetchWithRetry(args: { return { data, - status: response.status, - statusText: response.statusText, - headers: headersToObject(response.headers), + status: fetchResponse.status, + statusText: fetchResponse.statusText, + headers: fetchResponse.headers, } } catch (error) { lastError = error as Error diff --git a/src/utils/multipart-upload.ts b/src/utils/multipart-upload.ts index 665f7605..7a08349c 100644 --- a/src/utils/multipart-upload.ts +++ b/src/utils/multipart-upload.ts @@ -2,7 +2,7 @@ import FormData from 'form-data' import { createReadStream } from 'fs' import { basename } from 'path' import { fetchWithRetry } from './fetch-with-retry' -import type { HttpResponse } from '../types/http' +import type { HttpResponse, CustomFetch } from '../types/http' type UploadMultipartFileArgs = { baseUrl: string @@ -12,6 +12,7 @@ type UploadMultipartFileArgs = { fileName?: string additionalFields: Record requestId?: string + customFetch?: CustomFetch } /** @@ -78,7 +79,16 @@ function getContentTypeFromFileName(fileName: string): string { * ``` */ export async function uploadMultipartFile(args: UploadMultipartFileArgs): Promise { - const { baseUrl, authToken, endpoint, file, fileName, additionalFields, requestId } = args + const { + baseUrl, + authToken, + endpoint, + file, + fileName, + additionalFields, + requestId, + customFetch, + } = args const form = new FormData() // Determine file type and add to form data @@ -137,6 +147,7 @@ export async function uploadMultipartFile(args: UploadMultipartFileArgs): Pro headers, timeout: 30000, // 30 second timeout for file uploads }, + customFetch, }) return response.data From 6a72c83acd4ae9d491a1d221c85d06713a929f69 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sat, 8 Nov 2025 11:52:56 +0000 Subject: [PATCH 2/3] fix: update custom-fetch-simple.test.ts to work with MSW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rebasing onto main, the test file needed to be updated to work with MSW (Mock Service Worker) which replaced the old jest.fn() mocking approach. Updated the test to: - Import types from index file instead of direct imports - Import MSW utilities from test-utils/msw-setup - Use proper MSW handlers for mocking endpoints - Include complete CurrentUser mock data to satisfy validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/custom-fetch-simple.test.ts | 76 +++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/src/custom-fetch-simple.test.ts b/src/custom-fetch-simple.test.ts index 30ff7a42..698b4218 100644 --- a/src/custom-fetch-simple.test.ts +++ b/src/custom-fetch-simple.test.ts @@ -1,12 +1,44 @@ -import { TodoistApi } from './todoist-api' +import { TodoistApi, type CurrentUser } from '.' import { CustomFetch, CustomFetchResponse } from './types/http' - -// Mock fetch globally -const mockFetch = jest.fn() -global.fetch = mockFetch as unknown as typeof fetch +import { server, http, HttpResponse } from './test-utils/msw-setup' +import { getSyncBaseUri, ENDPOINT_REST_USER } from './consts/endpoints' const DEFAULT_AUTH_TOKEN = 'test-auth-token' +const MOCK_CURRENT_USER: CurrentUser = { + id: '123456789', + email: 'test.user@example.com', + fullName: 'Test User', + avatarBig: 'https://example.com/avatars/test_user_big.jpg', + avatarMedium: 'https://example.com/avatars/test_user_medium.jpg', + avatarS640: 'https://example.com/avatars/test_user_s640.jpg', + avatarSmall: 'https://example.com/avatars/test_user_small.jpg', + businessAccountId: null, + isPremium: true, + dateFormat: 0, + timeFormat: 0, + weeklyGoal: 100, + dailyGoal: 10, + completedCount: 102920, + completedToday: 12, + karma: 86394.0, + karmaTrend: 'up', + lang: 'en', + nextWeek: 1, + startDay: 1, + startPage: 'project?id=test_project_123', + tzInfo: { + gmtString: '+02:00', + hours: 2, + isDst: 1, + minutes: 0, + timezone: 'Europe/Madrid', + }, + inboxProjectId: 'test_project_123', + daysOff: [6, 7], + weekendStartDay: 6, +} + describe('Custom Fetch Core Functionality', () => { beforeEach(() => { jest.clearAllMocks() @@ -38,22 +70,18 @@ describe('Custom Fetch Core Functionality', () => { status: 200, statusText: 'OK', headers: { 'content-type': 'application/json' }, - text: () => Promise.resolve('{"id":"123"}'), - json: () => Promise.resolve({ id: '123' }), + text: () => Promise.resolve(JSON.stringify(MOCK_CURRENT_USER)), + json: () => Promise.resolve(MOCK_CURRENT_USER), } as CustomFetchResponse) const api = new TodoistApi(DEFAULT_AUTH_TOKEN, { customFetch: mockCustomFetch, }) - try { - await api.getUser() - } catch (error) { - // Expected to fail validation, but custom fetch should be called - } + await api.getUser() expect(mockCustomFetch).toHaveBeenCalledWith( - 'https://api.todoist.com/api/v1/user', + `${getSyncBaseUri()}${ENDPOINT_REST_USER}`, expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ @@ -64,24 +92,16 @@ describe('Custom Fetch Core Functionality', () => { }) it('should use native fetch when no custom fetch provided', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - headers: new Map([['content-type', 'application/json']]), - text: jest.fn().mockResolvedValue('{"id":"123"}'), - json: jest.fn().mockResolvedValue({ id: '123' }), - }) + server.use( + http.get(`${getSyncBaseUri()}${ENDPOINT_REST_USER}`, () => { + return HttpResponse.json(MOCK_CURRENT_USER, { status: 200 }) + }), + ) const api = new TodoistApi(DEFAULT_AUTH_TOKEN) + const user = await api.getUser() - try { - await api.getUser() - } catch (error) { - // Expected to fail validation, but native fetch should be called - } - - expect(mockFetch).toHaveBeenCalled() + expect(user).toEqual(MOCK_CURRENT_USER) }) }) }) From 36e2a3dcbc0a184bae3c9dff1843c4ec84f1ba8e Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sat, 8 Nov 2025 12:23:12 +0000 Subject: [PATCH 3/3] feat: add Obsidian requestUrl integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive test suite demonstrating custom fetch working with Obsidian's requestUrl API, addressing issue #381. Changes: - Install obsidian package as devDependency (not shipped to production) - Create createObsidianFetchAdapter helper that bridges between: * Obsidian's property-based response (response.json, response.text) * SDK's method-based interface (response.json(), response.text()) - Add obsidian-custom-fetch.test.ts with 7 test cases covering: * GET requests (simple, with path params, with query params) * POST requests (with body, with path params and body) * DELETE requests (204 no-content responses) * Error handling The adapter handles key differences: - Sets throw: false to handle HTTP errors manually - Maps Obsidian's response format to CustomFetchResponse interface - Provides empty statusText (not available in Obsidian) - Wraps direct properties as async methods This demonstrates the custom fetch feature works in restrictive environments like Obsidian plugins where standard fetch is blocked by CORS policies. Resolves #381 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package-lock.json | 194 ++++++++++++++- package.json | 1 + src/obsidian-custom-fetch.test.ts | 288 +++++++++++++++++++++++ src/test-utils/obsidian-fetch-adapter.ts | 60 +++++ 4 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 src/obsidian-custom-fetch.test.ts create mode 100644 src/test-utils/obsidian-fetch-adapter.ts diff --git a/package-lock.json b/package-lock.json index 1316b34c..9bcd28a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "lint-staged": "16.1.6", "msw": "2.11.6", "npm-run-all2": "8.0.4", + "obsidian": "^1.10.2-1", "prettier": "3.3.2", "rimraf": "6.0.1", "ts-jest": "29.4.5", @@ -595,6 +596,31 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", + "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1614,6 +1640,14 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@mswjs/interceptors": { "version": "0.40.0", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", @@ -1830,6 +1864,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -1856,8 +1900,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -1939,6 +1982,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -3371,6 +3424,14 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6920,6 +6981,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7248,6 +7319,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obsidian": { + "version": "1.10.2-1", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.10.2-1.tgz", + "integrity": "sha512-nScksVndWZDz0e/OAPa7Yo0aFaNuv6amyHg2L6MvlxfTJD6TfmkprVWhHjiVvUIGwcdvZRy6LGkn/v8D2E9jng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.1" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8394,6 +8480,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -8963,6 +9057,14 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -9690,6 +9792,29 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "peer": true, + "requires": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "@codemirror/view": { + "version": "6.38.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", + "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", + "dev": true, + "peer": true, + "requires": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -10446,6 +10571,13 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "peer": true + }, "@mswjs/interceptors": { "version": "0.40.0", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", @@ -10632,6 +10764,15 @@ "@babel/types": "^7.28.2" } }, + "@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "requires": { + "@types/tern": "*" + } + }, "@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -10658,8 +10799,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "peer": true + "dev": true }, "@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -10734,6 +10874,15 @@ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "dev": true }, + "@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "requires": { + "@types/estree": "*" + } + }, "@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -11722,6 +11871,13 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "peer": true + }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -14285,6 +14441,12 @@ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true }, + "moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true + }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -14502,6 +14664,16 @@ "es-abstract": "^1.20.4" } }, + "obsidian": { + "version": "1.10.2-1", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.10.2-1.tgz", + "integrity": "sha512-nScksVndWZDz0e/OAPa7Yo0aFaNuv6amyHg2L6MvlxfTJD6TfmkprVWhHjiVvUIGwcdvZRy6LGkn/v8D2E9jng==", + "dev": true, + "requires": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + } + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -15295,6 +15467,13 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "peer": true + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -15666,6 +15845,13 @@ } } }, + "w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "peer": true + }, "walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index 99a5f8db..0dbb6f85 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "lint-staged": "16.1.6", "msw": "2.11.6", "npm-run-all2": "8.0.4", + "obsidian": "^1.10.2-1", "prettier": "3.3.2", "rimraf": "6.0.1", "ts-jest": "29.4.5", diff --git a/src/obsidian-custom-fetch.test.ts b/src/obsidian-custom-fetch.test.ts new file mode 100644 index 00000000..f204fe42 --- /dev/null +++ b/src/obsidian-custom-fetch.test.ts @@ -0,0 +1,288 @@ +/** + * Integration tests demonstrating custom fetch with Obsidian's requestUrl API. + * + * These tests validate that the Todoist API SDK works correctly in Obsidian plugins, + * which have restricted networking that requires using Obsidian's requestUrl instead + * of standard fetch. + * + * This addresses issue #381: https://github.com/Doist/todoist-api-typescript/issues/381 + */ + +import type { RequestUrlParam, RequestUrlResponse } from 'obsidian' +import { TodoistApi, type CurrentUser } from '.' +import { createObsidianFetchAdapter } from './test-utils/obsidian-fetch-adapter' +import { server, http, HttpResponse } from './test-utils/msw-setup' +import { + getSyncBaseUri, + ENDPOINT_REST_USER, + ENDPOINT_REST_TASKS, + ENDPOINT_REST_PROJECTS, + ENDPOINT_REST_LABELS, +} from './consts/endpoints' +import { DEFAULT_AUTH_TOKEN, DEFAULT_TASK, DEFAULT_LABEL } from './test-utils/test-defaults' + +describe('Obsidian Custom Fetch Integration', () => { + // Mock Obsidian's requestUrl function + const mockRequestUrl = jest.fn, [RequestUrlParam | string]>() + + beforeEach(() => { + jest.clearAllMocks() + + // Configure mock to call through to MSW and return Obsidian-shaped responses + mockRequestUrl.mockImplementation(async (request: RequestUrlParam | string) => { + const params = typeof request === 'string' ? { url: request } : request + const url = params.url + const method = params.method || 'GET' + const headers = params.headers || {} + const body = params.body + + // Make actual request to MSW handlers + const response = await fetch(url, { + method, + headers, + body: body as BodyInit, + }) + + // Clone response to read body twice + const responseClone = response.clone() + const text = await response.text() + let json: unknown + try { + json = text ? JSON.parse(text) : null + } catch { + json = null + } + + // Return Obsidian-shaped response (properties, not methods) + return { + status: responseClone.status, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call + headers: Object.fromEntries(responseClone.headers.entries()), + arrayBuffer: await responseClone.arrayBuffer(), + json, + text, + } + }) + }) + + describe('GET requests', () => { + it('should get current user (simple GET, no parameters)', async () => { + const mockUser: CurrentUser = { + id: '123456789', + email: 'test@example.com', + fullName: 'Test User', + avatarBig: null, + avatarMedium: null, + avatarS640: null, + avatarSmall: null, + businessAccountId: null, + isPremium: true, + dateFormat: 0, + timeFormat: 0, + weeklyGoal: 100, + dailyGoal: 10, + completedCount: 1000, + completedToday: 5, + karma: 5000.0, + karmaTrend: 'up', + lang: 'en', + nextWeek: 1, + startDay: 1, + startPage: 'project?id=123', + tzInfo: { + gmtString: '+00:00', + hours: 0, + isDst: 0, + minutes: 0, + timezone: 'UTC', + }, + inboxProjectId: '123', + daysOff: [6, 7], + weekendStartDay: 6, + } + + server.use( + http.get(`${getSyncBaseUri()}${ENDPOINT_REST_USER}`, () => { + return HttpResponse.json(mockUser, { status: 200 }) + }), + ) + + const api = new TodoistApi(DEFAULT_AUTH_TOKEN, { + customFetch: createObsidianFetchAdapter(mockRequestUrl), + }) + + const user = await api.getUser() + + expect(user).toEqual(mockUser) + expect(mockRequestUrl).toHaveBeenCalledWith({ + url: `${getSyncBaseUri()}${ENDPOINT_REST_USER}`, + method: 'GET', + headers: expect.objectContaining({ + Authorization: `Bearer ${DEFAULT_AUTH_TOKEN}`, + }), + body: undefined, + throw: false, + }) + }) + + it('should get task by id (GET with path parameter)', async () => { + const taskId = '123' + server.use( + http.get(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}/${taskId}`, () => { + return HttpResponse.json(DEFAULT_TASK, { status: 200 }) + }), + ) + + const api = new TodoistApi(DEFAULT_AUTH_TOKEN, { + customFetch: createObsidianFetchAdapter(mockRequestUrl), + }) + + const task = await api.getTask(taskId) + + expect(task).toEqual(DEFAULT_TASK) + expect(mockRequestUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${getSyncBaseUri()}${ENDPOINT_REST_TASKS}/${taskId}`, + method: 'GET', + }), + ) + }) + + it('should get tasks with filters (GET with query parameters)', async () => { + const projectId = '123' + const tasks = [DEFAULT_TASK] + + server.use( + http.get(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}`, ({ request }) => { + const url = new URL(request.url) + expect(url.searchParams.get('project_id')).toBe(projectId) + return HttpResponse.json({ results: tasks, nextCursor: null }, { status: 200 }) + }), + ) + + const api = new TodoistApi(DEFAULT_AUTH_TOKEN, { + customFetch: createObsidianFetchAdapter(mockRequestUrl), + }) + + const { results, nextCursor } = await api.getTasks({ projectId }) + + expect(results).toEqual(tasks) + expect(nextCursor).toBeNull() + expect(mockRequestUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining('project_id=123'), + method: 'GET', + }), + ) + }) + }) + + describe('POST requests', () => { + it('should add task (POST with JSON body)', async () => { + const newTask = { content: 'New task' } + + server.use( + http.post(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}`, async ({ request }) => { + const body = await request.json() + expect(body).toEqual(newTask) + return HttpResponse.json(DEFAULT_TASK, { status: 200 }) + }), + ) + + const api = new TodoistApi(DEFAULT_AUTH_TOKEN, { + customFetch: createObsidianFetchAdapter(mockRequestUrl), + }) + + const task = await api.addTask(newTask) + + expect(task).toEqual(DEFAULT_TASK) + expect(mockRequestUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${getSyncBaseUri()}${ENDPOINT_REST_TASKS}`, + method: 'POST', + body: JSON.stringify(newTask), + }), + ) + }) + + it('should update label (POST with path parameter and body)', async () => { + const labelId = '456' + const updates = { name: 'Updated Label' } + + server.use( + http.post( + `${getSyncBaseUri()}${ENDPOINT_REST_LABELS}/${labelId}`, + async ({ request }) => { + const body = await request.json() + expect(body).toEqual(updates) + return HttpResponse.json({ ...DEFAULT_LABEL, ...updates }, { status: 200 }) + }, + ), + ) + + const api = new TodoistApi(DEFAULT_AUTH_TOKEN, { + customFetch: createObsidianFetchAdapter(mockRequestUrl), + }) + + const label = await api.updateLabel(labelId, updates) + + expect(label.name).toBe('Updated Label') + expect(mockRequestUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${getSyncBaseUri()}${ENDPOINT_REST_LABELS}/${labelId}`, + method: 'POST', + body: JSON.stringify(updates), + }), + ) + }) + }) + + describe('DELETE requests', () => { + it('should delete project (DELETE returning 204)', async () => { + const projectId = '789' + + server.use( + http.delete(`${getSyncBaseUri()}${ENDPOINT_REST_PROJECTS}/${projectId}`, () => { + return new HttpResponse(null, { status: 204 }) + }), + ) + + const api = new TodoistApi(DEFAULT_AUTH_TOKEN, { + customFetch: createObsidianFetchAdapter(mockRequestUrl), + }) + + const result = await api.deleteProject(projectId) + + expect(result).toBe(true) + expect(mockRequestUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${getSyncBaseUri()}${ENDPOINT_REST_PROJECTS}/${projectId}`, + method: 'DELETE', + }), + ) + }) + }) + + describe('Error handling', () => { + it('should handle HTTP errors correctly', async () => { + server.use( + http.get(`${getSyncBaseUri()}${ENDPOINT_REST_USER}`, () => { + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) + }), + ) + + const api = new TodoistApi(DEFAULT_AUTH_TOKEN, { + customFetch: createObsidianFetchAdapter(mockRequestUrl), + }) + + await expect(api.getUser()).rejects.toThrow() + + // Verify throw: false was set (so we can handle errors) + expect(mockRequestUrl).toHaveBeenCalledWith( + expect.objectContaining({ + throw: false, + }), + ) + }) + }) +}) diff --git a/src/test-utils/obsidian-fetch-adapter.ts b/src/test-utils/obsidian-fetch-adapter.ts new file mode 100644 index 00000000..6fee36e4 --- /dev/null +++ b/src/test-utils/obsidian-fetch-adapter.ts @@ -0,0 +1,60 @@ +import type { RequestUrlParam, RequestUrlResponse } from 'obsidian' +import type { CustomFetch, CustomFetchResponse } from '../types/http' + +/** + * Creates a CustomFetch adapter for Obsidian's requestUrl API. + * + * This adapter bridges the gap between Obsidian's requestUrl interface and the + * standard fetch-like interface expected by the Todoist API SDK. + * + * Key differences handled by this adapter: + * - Obsidian returns response data as properties (response.json, response.text) + * while the SDK expects methods (response.json(), response.text()) + * - Obsidian's requestUrl bypasses CORS restrictions that would block standard fetch + * - Obsidian throws on HTTP errors by default; we set throw: false to handle manually + * - Obsidian doesn't provide statusText; we default to empty string + * + * @example + * ```typescript + * import { requestUrl } from 'obsidian'; + * import { createObsidianFetchAdapter } from './obsidian-fetch-adapter'; + * + * const api = new TodoistApi('your-token', { + * customFetch: createObsidianFetchAdapter(requestUrl) + * }); + * ``` + * + * @param requestUrl - The Obsidian requestUrl function + * @returns A CustomFetch function compatible with the Todoist API SDK + */ +export function createObsidianFetchAdapter( + requestUrl: (request: RequestUrlParam | string) => Promise, +): CustomFetch { + return async ( + url: string, + options?: RequestInit & { timeout?: number }, + ): Promise => { + // Build the request parameters in Obsidian's format + const requestParams: RequestUrlParam = { + url, + method: options?.method || 'GET', + headers: options?.headers as Record | undefined, + body: options?.body as string | ArrayBuffer | undefined, + throw: false, // Don't throw on HTTP errors; let the SDK handle status codes + } + + // Make the request using Obsidian's requestUrl + const response = await requestUrl(requestParams) + + // Transform Obsidian's response format to match CustomFetchResponse interface + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: '', // Obsidian doesn't provide statusText + headers: response.headers, + // Wrap Obsidian's direct properties as methods returning promises + text: () => Promise.resolve(response.text), + json: () => Promise.resolve(response.json as unknown), + } + } +}