Skip to content

Commit c3acd1b

Browse files
feat: add custom HTTP client support for cross-platform compatibility
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 <[email protected]>
1 parent 1d5c150 commit c3acd1b

File tree

9 files changed

+426
-25
lines changed

9 files changed

+426
-25
lines changed

README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,78 @@ Key changes in v1 include:
3838
- Object renames (e.g., items → tasks, notes → comments)
3939
- URL renames and endpoint signature changes
4040

41+
## Custom HTTP Clients
42+
43+
The Todoist API client supports custom HTTP implementations to enable usage in environments with specific networking requirements, such as:
44+
45+
- **Obsidian plugins** - Desktop app with strict CORS policies
46+
- **Browser extensions** - Custom HTTP APIs with different security models
47+
- **Electron apps** - Requests routed through IPC layer
48+
- **React Native** - Different networking stack
49+
- **Enterprise environments** - Proxy configuration, custom headers, or certificate handling
50+
51+
### Basic Usage
52+
53+
```typescript
54+
import { TodoistApi } from '@doist/todoist-api-typescript'
55+
56+
// Using the new options-based constructor
57+
const api = new TodoistApi('YOURTOKEN', {
58+
baseUrl: 'https://custom-api.example.com', // optional
59+
customFetch: myCustomFetch, // your custom fetch implementation
60+
})
61+
62+
// Legacy constructor (deprecated but supported)
63+
const apiLegacy = new TodoistApi('YOURTOKEN', 'https://custom-api.example.com')
64+
```
65+
66+
### Custom Fetch Interface
67+
68+
Your custom fetch function must implement this interface:
69+
70+
```typescript
71+
type CustomFetch = (
72+
url: string,
73+
options?: RequestInit & { timeout?: number },
74+
) => Promise<CustomFetchResponse>
75+
76+
type CustomFetchResponse = {
77+
ok: boolean
78+
status: number
79+
statusText: string
80+
headers: Record<string, string>
81+
text(): Promise<string>
82+
json(): Promise<unknown>
83+
}
84+
```
85+
86+
### OAuth with Custom Fetch
87+
88+
OAuth authentication functions (`getAuthToken`, `revokeAuthToken`, `revokeToken`) support custom fetch through an options object:
89+
90+
```typescript
91+
// New options-based usage
92+
const { accessToken } = await getAuthToken(args, {
93+
baseUrl: 'https://custom-auth.example.com',
94+
customFetch: myCustomFetch,
95+
})
96+
97+
await revokeToken(args, {
98+
customFetch: myCustomFetch,
99+
})
100+
101+
// Legacy usage (deprecated)
102+
const { accessToken } = await getAuthToken(args, baseUrl)
103+
```
104+
105+
### Important Notes
106+
107+
- All existing transforms (snake_case ↔ camelCase) work automatically with custom fetch
108+
- Retry logic and error handling are preserved
109+
- File uploads work with custom fetch implementations
110+
- The custom fetch function should handle FormData for multipart uploads
111+
- Timeout parameter is optional and up to your custom implementation
112+
41113
## Development and Testing
42114

43115
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.

src/authentication.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { request, isSuccess } from './rest-client'
22
import { v4 as uuid } from 'uuid'
33
import { TodoistRequestError } from './types'
4+
import { CustomFetch } from './types/http'
45
import {
56
getAuthBaseUri,
67
getSyncBaseUri,
@@ -10,6 +11,14 @@ import {
1011
ENDPOINT_REVOKE,
1112
} from './consts/endpoints'
1213

14+
/**
15+
* Options for authentication functions
16+
*/
17+
export type AuthOptions = {
18+
baseUrl?: string
19+
customFetch?: CustomFetch
20+
}
21+
1322
/**
1423
* Permission scopes that can be requested during OAuth2 authorization.
1524
* @see {@link https://todoist.com/api/v1/docs#tag/Authorization}
@@ -140,17 +149,43 @@ export function getAuthorizationUrl({
140149
* @returns The access token response
141150
* @throws {@link TodoistRequestError} If the token exchange fails
142151
*/
152+
// Function overloads for backward compatibility
153+
/**
154+
* @deprecated Use options object instead: getAuthToken(args, { baseUrl, customFetch })
155+
*/
143156
export async function getAuthToken(
144157
args: AuthTokenRequestArgs,
145158
baseUrl?: string,
159+
): Promise<AuthTokenResponse>
160+
export async function getAuthToken(
161+
args: AuthTokenRequestArgs,
162+
options?: AuthOptions,
163+
): Promise<AuthTokenResponse>
164+
export async function getAuthToken(
165+
args: AuthTokenRequestArgs,
166+
baseUrlOrOptions?: string | AuthOptions,
146167
): Promise<AuthTokenResponse> {
168+
let baseUrl: string | undefined
169+
let customFetch: CustomFetch | undefined
170+
171+
if (typeof baseUrlOrOptions === 'string') {
172+
// Legacy signature: (args, baseUrl)
173+
baseUrl = baseUrlOrOptions
174+
customFetch = undefined
175+
} else if (baseUrlOrOptions) {
176+
// New signature: (args, options)
177+
baseUrl = baseUrlOrOptions.baseUrl
178+
customFetch = baseUrlOrOptions.customFetch
179+
}
180+
147181
try {
148182
const response = await request<AuthTokenResponse>({
149183
httpMethod: 'POST',
150184
baseUri: getAuthBaseUri(baseUrl),
151185
relativePath: ENDPOINT_GET_TOKEN,
152186
apiToken: undefined,
153187
payload: args,
188+
customFetch,
154189
})
155190

156191
if (response.status !== 200 || !response.data?.accessToken) {
@@ -189,16 +224,41 @@ export async function getAuthToken(
189224
* @returns True if revocation was successful
190225
* @see https://todoist.com/api/v1/docs#tag/Authorization/operation/revoke_access_token_api_api_v1_access_tokens_delete
191226
*/
227+
// Function overloads for backward compatibility
228+
/**
229+
* @deprecated Use options object instead: revokeAuthToken(args, { baseUrl, customFetch })
230+
*/
192231
export async function revokeAuthToken(
193232
args: RevokeAuthTokenRequestArgs,
194233
baseUrl?: string,
234+
): Promise<boolean>
235+
export async function revokeAuthToken(
236+
args: RevokeAuthTokenRequestArgs,
237+
options?: AuthOptions,
238+
): Promise<boolean>
239+
export async function revokeAuthToken(
240+
args: RevokeAuthTokenRequestArgs,
241+
baseUrlOrOptions?: string | AuthOptions,
195242
): Promise<boolean> {
243+
let baseUrl: string | undefined
244+
let customFetch: CustomFetch | undefined
245+
246+
if (typeof baseUrlOrOptions === 'string') {
247+
// Legacy signature: (args, baseUrl)
248+
baseUrl = baseUrlOrOptions
249+
customFetch = undefined
250+
} else if (baseUrlOrOptions) {
251+
// New signature: (args, options)
252+
baseUrl = baseUrlOrOptions.baseUrl
253+
customFetch = baseUrlOrOptions.customFetch
254+
}
196255
const response = await request({
197256
httpMethod: 'POST',
198257
baseUri: getSyncBaseUri(baseUrl),
199258
relativePath: ENDPOINT_REVOKE_TOKEN,
200259
apiToken: undefined,
201260
payload: args,
261+
customFetch,
202262
})
203263

204264
return isSuccess(response)
@@ -223,10 +283,31 @@ export async function revokeAuthToken(
223283
* @see https://datatracker.ietf.org/doc/html/rfc7009
224284
* @see https://todoist.com/api/v1/docs#tag/Authorization
225285
*/
286+
// Function overloads for backward compatibility
287+
/**
288+
* @deprecated Use options object instead: revokeToken(args, { baseUrl, customFetch })
289+
*/
290+
export async function revokeToken(args: RevokeTokenRequestArgs, baseUrl?: string): Promise<boolean>
226291
export async function revokeToken(
227292
args: RevokeTokenRequestArgs,
228-
baseUrl?: string,
293+
options?: AuthOptions,
294+
): Promise<boolean>
295+
export async function revokeToken(
296+
args: RevokeTokenRequestArgs,
297+
baseUrlOrOptions?: string | AuthOptions,
229298
): Promise<boolean> {
299+
let baseUrl: string | undefined
300+
let customFetch: CustomFetch | undefined
301+
302+
if (typeof baseUrlOrOptions === 'string') {
303+
// Legacy signature: (args, baseUrl)
304+
baseUrl = baseUrlOrOptions
305+
customFetch = undefined
306+
} else if (baseUrlOrOptions) {
307+
// New signature: (args, options)
308+
baseUrl = baseUrlOrOptions.baseUrl
309+
customFetch = baseUrlOrOptions.customFetch
310+
}
230311
const { clientId, clientSecret, token } = args
231312

232313
// Create Basic Auth header as per RFC 7009
@@ -250,6 +331,7 @@ export async function revokeToken(
250331
requestId: undefined,
251332
hasSyncCommands: false,
252333
customHeaders: customHeaders,
334+
customFetch,
253335
})
254336

255337
return isSuccess(response)

src/custom-fetch-simple.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { TodoistApi } from './todoist-api'
2+
import { CustomFetch, CustomFetchResponse } from './types/http'
3+
4+
// Mock fetch globally
5+
const mockFetch = jest.fn()
6+
global.fetch = mockFetch as unknown as typeof fetch
7+
8+
const DEFAULT_AUTH_TOKEN = 'test-auth-token'
9+
10+
describe('Custom Fetch Core Functionality', () => {
11+
beforeEach(() => {
12+
jest.clearAllMocks()
13+
})
14+
15+
describe('Constructor Options', () => {
16+
it('should accept customFetch in options', () => {
17+
const mockCustomFetch: CustomFetch = jest.fn()
18+
const api = new TodoistApi(DEFAULT_AUTH_TOKEN, {
19+
customFetch: mockCustomFetch,
20+
})
21+
expect(api).toBeInstanceOf(TodoistApi)
22+
})
23+
24+
it('should show deprecation warning for old constructor', () => {
25+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
26+
const api = new TodoistApi(DEFAULT_AUTH_TOKEN, 'https://custom.api.com')
27+
28+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('deprecated'))
29+
expect(api).toBeInstanceOf(TodoistApi)
30+
consoleSpy.mockRestore()
31+
})
32+
})
33+
34+
describe('Custom Fetch Usage', () => {
35+
it('should call custom fetch when provided', async () => {
36+
const mockCustomFetch = jest.fn().mockResolvedValue({
37+
ok: true,
38+
status: 200,
39+
statusText: 'OK',
40+
headers: { 'content-type': 'application/json' },
41+
text: () => Promise.resolve('{"id":"123"}'),
42+
json: () => Promise.resolve({ id: '123' }),
43+
} as CustomFetchResponse)
44+
45+
const api = new TodoistApi(DEFAULT_AUTH_TOKEN, {
46+
customFetch: mockCustomFetch,
47+
})
48+
49+
try {
50+
await api.getUser()
51+
} catch (error) {
52+
// Expected to fail validation, but custom fetch should be called
53+
}
54+
55+
expect(mockCustomFetch).toHaveBeenCalledWith(
56+
'https://api.todoist.com/api/v1/user',
57+
expect.objectContaining({
58+
method: 'GET',
59+
headers: expect.objectContaining({
60+
Authorization: `Bearer ${DEFAULT_AUTH_TOKEN}`,
61+
}),
62+
}),
63+
)
64+
})
65+
66+
it('should use native fetch when no custom fetch provided', async () => {
67+
mockFetch.mockResolvedValue({
68+
ok: true,
69+
status: 200,
70+
statusText: 'OK',
71+
headers: new Map([['content-type', 'application/json']]),
72+
text: jest.fn().mockResolvedValue('{"id":"123"}'),
73+
json: jest.fn().mockResolvedValue({ id: '123' }),
74+
})
75+
76+
const api = new TodoistApi(DEFAULT_AUTH_TOKEN)
77+
78+
try {
79+
await api.getUser()
80+
} catch (error) {
81+
// Expected to fail validation, but native fetch should be called
82+
}
83+
84+
expect(mockFetch).toHaveBeenCalled()
85+
})
86+
})
87+
})

src/rest-client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { TodoistRequestError } from './types/errors'
2-
import { HttpMethod, HttpResponse, isNetworkError, isHttpError } from './types/http'
2+
import { HttpMethod, HttpResponse, isNetworkError, isHttpError, CustomFetch } from './types/http'
33
import { v4 as uuidv4 } from 'uuid'
44
import { API_BASE_URI } from './consts/endpoints'
55
import { camelCaseKeys, snakeCaseKeys } from './utils/case-conversion'
@@ -33,6 +33,7 @@ type RequestArgs = {
3333
requestId?: string
3434
hasSyncCommands?: boolean
3535
customHeaders?: Record<string, string>
36+
customFetch?: CustomFetch
3637
}
3738

3839
export function paramsSerializer(params: Record<string, unknown>) {
@@ -116,6 +117,7 @@ export async function request<T>(args: RequestArgs): Promise<HttpResponse<T>> {
116117
requestId: initialRequestId,
117118
hasSyncCommands,
118119
customHeaders,
120+
customFetch,
119121
} = args
120122

121123
// Capture original stack for better error reporting
@@ -174,6 +176,7 @@ export async function request<T>(args: RequestArgs): Promise<HttpResponse<T>> {
174176
url: finalUrl,
175177
options: fetchOptions,
176178
retryConfig: config.retry,
179+
customFetch,
177180
})
178181

179182
// Convert snake_case response to camelCase

0 commit comments

Comments
 (0)