@openlifelog/sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/EXAMPLES.md +624 -0
- package/README.md +824 -0
- package/client.ts +190 -0
- package/config.ts +96 -0
- package/constants/metrics.ts +116 -0
- package/dist/index.d.mts +1101 -0
- package/dist/index.d.ts +1101 -0
- package/dist/index.js +2023 -0
- package/dist/index.mjs +1969 -0
- package/index.ts +49 -0
- package/package.json +53 -0
- package/resources/ai.ts +26 -0
- package/resources/auth.ts +98 -0
- package/resources/exercises.ts +112 -0
- package/resources/food-logs.ts +132 -0
- package/resources/foods.ts +185 -0
- package/resources/goals.ts +155 -0
- package/resources/metrics.ts +115 -0
- package/resources/programs.ts +123 -0
- package/resources/sessions.ts +142 -0
- package/resources/users.ts +132 -0
- package/resources/workouts.ts +147 -0
- package/tsconfig.json +27 -0
- package/types/ai.ts +55 -0
- package/types/common.ts +177 -0
- package/types/exercise.ts +75 -0
- package/types/food.ts +208 -0
- package/types/goal.ts +169 -0
- package/types/index.ts +17 -0
- package/types/metric.ts +108 -0
- package/types/program.ts +120 -0
- package/types/session.ts +196 -0
- package/types/user.ts +79 -0
- package/types/workout.ts +97 -0
- package/utils/errors.ts +159 -0
- package/utils/http.ts +313 -0
- package/utils/index.ts +8 -0
- package/utils/pagination.ts +106 -0
- package/utils/units.ts +279 -0
package/types/workout.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { DateTime, UUID, ListParams, WorkoutFormat } from './common';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Workout template types
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Set data structure (flexible for different workout formats)
|
|
9
|
+
*/
|
|
10
|
+
export interface SetData {
|
|
11
|
+
order: number;
|
|
12
|
+
reps?: number;
|
|
13
|
+
weight?: number;
|
|
14
|
+
restSeconds?: number;
|
|
15
|
+
distance?: number;
|
|
16
|
+
duration?: number;
|
|
17
|
+
tempo?: string;
|
|
18
|
+
rpe?: number;
|
|
19
|
+
notes?: string;
|
|
20
|
+
[key: string]: any; // Allow additional format-specific fields
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Workout exercise (exercise within a workout template)
|
|
25
|
+
*/
|
|
26
|
+
export interface WorkoutExercise {
|
|
27
|
+
id: UUID;
|
|
28
|
+
workoutId: UUID;
|
|
29
|
+
exerciseId: UUID;
|
|
30
|
+
orderIndex: number;
|
|
31
|
+
workoutFormat: WorkoutFormat | string;
|
|
32
|
+
setsData: SetData[];
|
|
33
|
+
isSuperset: boolean;
|
|
34
|
+
notes?: string;
|
|
35
|
+
exerciseName?: string;
|
|
36
|
+
exerciseDescription?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Workout template
|
|
41
|
+
*/
|
|
42
|
+
export interface Workout {
|
|
43
|
+
id: UUID;
|
|
44
|
+
name: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
type: string;
|
|
47
|
+
createdBy: UUID;
|
|
48
|
+
createdAt: DateTime;
|
|
49
|
+
updatedAt: DateTime;
|
|
50
|
+
exercises?: WorkoutExercise[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create workout request
|
|
55
|
+
*/
|
|
56
|
+
export interface CreateWorkoutRequest {
|
|
57
|
+
name: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
type: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Update workout request
|
|
64
|
+
*/
|
|
65
|
+
export type UpdateWorkoutRequest = Partial<CreateWorkoutRequest>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Add exercises to workout request
|
|
69
|
+
*/
|
|
70
|
+
export interface AddExercisesToWorkoutRequest {
|
|
71
|
+
exercises: Array<{
|
|
72
|
+
exerciseId: UUID;
|
|
73
|
+
orderIndex: number;
|
|
74
|
+
workoutFormat: WorkoutFormat | string;
|
|
75
|
+
setsData: SetData[];
|
|
76
|
+
isSuperset?: boolean;
|
|
77
|
+
notes?: string;
|
|
78
|
+
}>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Update workout exercise request
|
|
83
|
+
*/
|
|
84
|
+
export interface UpdateWorkoutExerciseRequest {
|
|
85
|
+
orderIndex?: number;
|
|
86
|
+
workoutFormat?: WorkoutFormat | string;
|
|
87
|
+
setsData?: SetData[];
|
|
88
|
+
isSuperset?: boolean;
|
|
89
|
+
notes?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* List workouts parameters
|
|
94
|
+
*/
|
|
95
|
+
export interface ListWorkoutsParams extends ListParams {
|
|
96
|
+
search?: string;
|
|
97
|
+
}
|
package/utils/errors.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom error classes for the OpenLifeLog SDK
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Base error class for all SDK errors
|
|
7
|
+
*/
|
|
8
|
+
export class OpenLifeLogError extends Error {
|
|
9
|
+
public readonly statusCode?: number;
|
|
10
|
+
public readonly code?: string;
|
|
11
|
+
public readonly rawError?: any;
|
|
12
|
+
|
|
13
|
+
constructor(message: string, statusCode?: number, code?: string, rawError?: any) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'OpenLifeLogError';
|
|
16
|
+
this.statusCode = statusCode;
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.rawError = rawError;
|
|
19
|
+
|
|
20
|
+
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
|
21
|
+
if (Error.captureStackTrace) {
|
|
22
|
+
Error.captureStackTrace(this, this.constructor);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Authentication error (401)
|
|
29
|
+
*/
|
|
30
|
+
export class AuthenticationError extends OpenLifeLogError {
|
|
31
|
+
constructor(message: string = 'Authentication failed. Please check your credentials.', rawError?: any) {
|
|
32
|
+
super(message, 401, 'AUTHENTICATION_ERROR', rawError);
|
|
33
|
+
this.name = 'AuthenticationError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Authorization error (403)
|
|
39
|
+
*/
|
|
40
|
+
export class AuthorizationError extends OpenLifeLogError {
|
|
41
|
+
constructor(message: string = 'You do not have permission to access this resource.', rawError?: any) {
|
|
42
|
+
super(message, 403, 'AUTHORIZATION_ERROR', rawError);
|
|
43
|
+
this.name = 'AuthorizationError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Not found error (404)
|
|
49
|
+
*/
|
|
50
|
+
export class NotFoundError extends OpenLifeLogError {
|
|
51
|
+
constructor(message: string = 'The requested resource was not found.', rawError?: any) {
|
|
52
|
+
super(message, 404, 'NOT_FOUND', rawError);
|
|
53
|
+
this.name = 'NotFoundError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validation error (400)
|
|
59
|
+
*/
|
|
60
|
+
export class ValidationError extends OpenLifeLogError {
|
|
61
|
+
public readonly validationErrors?: Record<string, string[]>;
|
|
62
|
+
|
|
63
|
+
constructor(
|
|
64
|
+
message: string = 'Validation failed.',
|
|
65
|
+
validationErrors?: Record<string, string[]>,
|
|
66
|
+
rawError?: any
|
|
67
|
+
) {
|
|
68
|
+
super(message, 400, 'VALIDATION_ERROR', rawError);
|
|
69
|
+
this.name = 'ValidationError';
|
|
70
|
+
this.validationErrors = validationErrors;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Rate limit error (429)
|
|
76
|
+
*/
|
|
77
|
+
export class RateLimitError extends OpenLifeLogError {
|
|
78
|
+
public readonly retryAfter?: number;
|
|
79
|
+
|
|
80
|
+
constructor(message: string = 'Rate limit exceeded. Please try again later.', retryAfter?: number, rawError?: any) {
|
|
81
|
+
super(message, 429, 'RATE_LIMIT_ERROR', rawError);
|
|
82
|
+
this.name = 'RateLimitError';
|
|
83
|
+
this.retryAfter = retryAfter;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Server error (500)
|
|
89
|
+
*/
|
|
90
|
+
export class ServerError extends OpenLifeLogError {
|
|
91
|
+
constructor(message: string = 'An internal server error occurred.', rawError?: any) {
|
|
92
|
+
super(message, 500, 'SERVER_ERROR', rawError);
|
|
93
|
+
this.name = 'ServerError';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Network error
|
|
99
|
+
*/
|
|
100
|
+
export class NetworkError extends OpenLifeLogError {
|
|
101
|
+
constructor(message: string = 'A network error occurred. Please check your connection.', rawError?: any) {
|
|
102
|
+
super(message, undefined, 'NETWORK_ERROR', rawError);
|
|
103
|
+
this.name = 'NetworkError';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Timeout error
|
|
109
|
+
*/
|
|
110
|
+
export class TimeoutError extends OpenLifeLogError {
|
|
111
|
+
constructor(message: string = 'The request timed out.', rawError?: any) {
|
|
112
|
+
super(message, 408, 'TIMEOUT_ERROR', rawError);
|
|
113
|
+
this.name = 'TimeoutError';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Unit conversion error
|
|
119
|
+
*/
|
|
120
|
+
export class UnitConversionError extends OpenLifeLogError {
|
|
121
|
+
constructor(message: string = 'Failed to convert units.', rawError?: any) {
|
|
122
|
+
super(message, undefined, 'UNIT_CONVERSION_ERROR', rawError);
|
|
123
|
+
this.name = 'UnitConversionError';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse the error response and create appropriate error instance
|
|
129
|
+
*/
|
|
130
|
+
export function parseErrorResponse(error: any, statusCode?: number): OpenLifeLogError {
|
|
131
|
+
const message = error?.message || error?.error || 'An unknown error occurred';
|
|
132
|
+
const rawError = error;
|
|
133
|
+
|
|
134
|
+
if (statusCode === 401) {
|
|
135
|
+
return new AuthenticationError(message, rawError);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (statusCode === 403) {
|
|
139
|
+
return new AuthorizationError(message, rawError);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (statusCode === 404) {
|
|
143
|
+
return new NotFoundError(message, rawError);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (statusCode === 400) {
|
|
147
|
+
return new ValidationError(message, error?.validationErrors, rawError);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (statusCode === 429) {
|
|
151
|
+
return new RateLimitError(message, error?.retryAfter, rawError);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (statusCode && statusCode >= 500) {
|
|
155
|
+
return new ServerError(message, rawError);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return new OpenLifeLogError(message, statusCode, error?.code, rawError);
|
|
159
|
+
}
|
package/utils/http.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { ResolvedConfig } from '../config';
|
|
2
|
+
import { NetworkError, TimeoutError, parseErrorResponse } from './errors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HTTP request options
|
|
6
|
+
*/
|
|
7
|
+
export interface RequestOptions {
|
|
8
|
+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
body?: any;
|
|
11
|
+
params?: Record<string, any>;
|
|
12
|
+
timeout?: number;
|
|
13
|
+
skipRetry?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* HTTP response wrapper
|
|
18
|
+
*/
|
|
19
|
+
export interface HttpResponse<T = any> {
|
|
20
|
+
data: T;
|
|
21
|
+
status: number;
|
|
22
|
+
headers: Headers;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* HTTP client with automatic retries, error handling, and timeout support
|
|
27
|
+
*/
|
|
28
|
+
export class HttpClient {
|
|
29
|
+
private config: ResolvedConfig;
|
|
30
|
+
private baseUrl: string;
|
|
31
|
+
|
|
32
|
+
constructor(config: ResolvedConfig) {
|
|
33
|
+
this.config = config;
|
|
34
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Update the API key (token)
|
|
39
|
+
*/
|
|
40
|
+
setApiKey(apiKey: string): void {
|
|
41
|
+
this.config.apiKey = apiKey;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get the current API key
|
|
46
|
+
*/
|
|
47
|
+
getApiKey(): string | undefined {
|
|
48
|
+
return this.config.apiKey;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert camelCase to snake_case
|
|
53
|
+
*/
|
|
54
|
+
private toSnakeCase(str: string): string {
|
|
55
|
+
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build full URL with query parameters
|
|
60
|
+
*/
|
|
61
|
+
private buildUrl(path: string, params?: Record<string, any>): string {
|
|
62
|
+
const url = new URL(path.startsWith('/') ? `${this.baseUrl}${path}` : `${this.baseUrl}/${path}`);
|
|
63
|
+
|
|
64
|
+
if (params) {
|
|
65
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
66
|
+
if (value !== undefined && value !== null) {
|
|
67
|
+
// Convert camelCase to snake_case for API compatibility
|
|
68
|
+
const snakeKey = this.toSnakeCase(key);
|
|
69
|
+
|
|
70
|
+
// Handle arrays
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
value.forEach((v) => url.searchParams.append(snakeKey, String(v)));
|
|
73
|
+
} else {
|
|
74
|
+
url.searchParams.append(snakeKey, String(value));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return url.toString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build request headers
|
|
85
|
+
*/
|
|
86
|
+
private buildHeaders(customHeaders?: Record<string, string>): Record<string, string> {
|
|
87
|
+
const headers: Record<string, string> = {
|
|
88
|
+
'Content-Type': 'application/json',
|
|
89
|
+
...this.config.headers,
|
|
90
|
+
...customHeaders,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Add authorization header if API key is present
|
|
94
|
+
if (this.config.apiKey) {
|
|
95
|
+
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return headers;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Execute request with timeout support
|
|
103
|
+
*/
|
|
104
|
+
private async executeWithTimeout(
|
|
105
|
+
url: string,
|
|
106
|
+
init: RequestInit,
|
|
107
|
+
timeout: number
|
|
108
|
+
): Promise<Response> {
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const response = await this.config.fetch(url, {
|
|
114
|
+
...init,
|
|
115
|
+
signal: controller.signal,
|
|
116
|
+
});
|
|
117
|
+
clearTimeout(timeoutId);
|
|
118
|
+
return response;
|
|
119
|
+
} catch (error: any) {
|
|
120
|
+
clearTimeout(timeoutId);
|
|
121
|
+
if (error.name === 'AbortError') {
|
|
122
|
+
throw new TimeoutError(`Request timed out after ${timeout}ms`);
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Sleep for exponential backoff
|
|
130
|
+
*/
|
|
131
|
+
private async sleep(ms: number): Promise<void> {
|
|
132
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Determine if error is retryable
|
|
137
|
+
*/
|
|
138
|
+
private isRetryableError(error: any, statusCode?: number): boolean {
|
|
139
|
+
// Network errors are retryable
|
|
140
|
+
if (error instanceof NetworkError || error instanceof TimeoutError) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 5xx errors are retryable
|
|
145
|
+
if (statusCode && statusCode >= 500 && statusCode < 600) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 429 (rate limit) is retryable
|
|
150
|
+
if (statusCode === 429) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Calculate exponential backoff delay
|
|
159
|
+
*/
|
|
160
|
+
private getRetryDelay(attempt: number): number {
|
|
161
|
+
// Exponential backoff: 2^attempt * 1000ms (with jitter)
|
|
162
|
+
const baseDelay = Math.pow(2, attempt) * 1000;
|
|
163
|
+
const jitter = Math.random() * 1000;
|
|
164
|
+
return baseDelay + jitter;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Make HTTP request with automatic retries
|
|
169
|
+
*/
|
|
170
|
+
async request<T = any>(path: string, options: RequestOptions = {}): Promise<HttpResponse<T>> {
|
|
171
|
+
const {
|
|
172
|
+
method = 'GET',
|
|
173
|
+
headers: customHeaders,
|
|
174
|
+
body,
|
|
175
|
+
params,
|
|
176
|
+
timeout = this.config.timeout,
|
|
177
|
+
skipRetry = false,
|
|
178
|
+
} = options;
|
|
179
|
+
|
|
180
|
+
const url = this.buildUrl(path, params);
|
|
181
|
+
const headers = this.buildHeaders(customHeaders);
|
|
182
|
+
|
|
183
|
+
const requestInit: RequestInit = {
|
|
184
|
+
method,
|
|
185
|
+
headers,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Add body for POST, PUT, PATCH requests
|
|
189
|
+
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
|
190
|
+
requestInit.body = JSON.stringify(body);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const maxAttempts = skipRetry || !this.config.enableRetries ? 1 : this.config.maxRetries + 1;
|
|
194
|
+
let lastError: any;
|
|
195
|
+
|
|
196
|
+
// Debug logging
|
|
197
|
+
if (this.config.debug) {
|
|
198
|
+
console.log(`[OpenLifeLog SDK] ${method} ${url}`, body ? { body } : '');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
202
|
+
try {
|
|
203
|
+
// Wait for exponential backoff (except first attempt)
|
|
204
|
+
if (attempt > 0) {
|
|
205
|
+
const delay = this.getRetryDelay(attempt - 1);
|
|
206
|
+
if (this.config.debug) {
|
|
207
|
+
console.log(`[OpenLifeLog SDK] Retry attempt ${attempt}/${maxAttempts - 1}, waiting ${delay}ms`);
|
|
208
|
+
}
|
|
209
|
+
await this.sleep(delay);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Execute request with timeout
|
|
213
|
+
const response = await this.executeWithTimeout(url, requestInit, timeout);
|
|
214
|
+
|
|
215
|
+
// Handle non-OK responses
|
|
216
|
+
if (!response.ok) {
|
|
217
|
+
let errorData: any;
|
|
218
|
+
try {
|
|
219
|
+
errorData = await response.json();
|
|
220
|
+
} catch {
|
|
221
|
+
errorData = { error: response.statusText };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const error = parseErrorResponse(errorData, response.status);
|
|
225
|
+
|
|
226
|
+
// Check if error is retryable
|
|
227
|
+
if (this.isRetryableError(error, response.status) && attempt < maxAttempts - 1) {
|
|
228
|
+
lastError = error;
|
|
229
|
+
continue; // Retry
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Parse response
|
|
236
|
+
let data: any;
|
|
237
|
+
const contentType = response.headers.get('content-type');
|
|
238
|
+
if (contentType?.includes('application/json')) {
|
|
239
|
+
data = await response.json();
|
|
240
|
+
} else {
|
|
241
|
+
data = await response.text();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Debug logging
|
|
245
|
+
if (this.config.debug) {
|
|
246
|
+
console.log(`[OpenLifeLog SDK] Response ${response.status}`, data);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
data,
|
|
251
|
+
status: response.status,
|
|
252
|
+
headers: response.headers,
|
|
253
|
+
};
|
|
254
|
+
} catch (error: any) {
|
|
255
|
+
// Convert network errors
|
|
256
|
+
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
257
|
+
lastError = new NetworkError(error.message, error);
|
|
258
|
+
} else if (error instanceof TimeoutError) {
|
|
259
|
+
lastError = error;
|
|
260
|
+
} else if (error.name === 'AbortError') {
|
|
261
|
+
lastError = new TimeoutError('Request was aborted', error);
|
|
262
|
+
} else {
|
|
263
|
+
lastError = error;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check if error is retryable
|
|
267
|
+
if (this.isRetryableError(lastError) && attempt < maxAttempts - 1) {
|
|
268
|
+
continue; // Retry
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
throw lastError;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// If we get here, all retries failed
|
|
276
|
+
throw lastError;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* GET request
|
|
281
|
+
*/
|
|
282
|
+
async get<T = any>(path: string, params?: Record<string, any>, options?: Omit<RequestOptions, 'method' | 'body' | 'params'>): Promise<HttpResponse<T>> {
|
|
283
|
+
return this.request<T>(path, { ...options, method: 'GET', params });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* POST request
|
|
288
|
+
*/
|
|
289
|
+
async post<T = any>(path: string, body?: any, options?: Omit<RequestOptions, 'method' | 'body'>): Promise<HttpResponse<T>> {
|
|
290
|
+
return this.request<T>(path, { ...options, method: 'POST', body });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* PUT request
|
|
295
|
+
*/
|
|
296
|
+
async put<T = any>(path: string, body?: any, options?: Omit<RequestOptions, 'method' | 'body'>): Promise<HttpResponse<T>> {
|
|
297
|
+
return this.request<T>(path, { ...options, method: 'PUT', body });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* PATCH request
|
|
302
|
+
*/
|
|
303
|
+
async patch<T = any>(path: string, body?: any, options?: Omit<RequestOptions, 'method' | 'body'>): Promise<HttpResponse<T>> {
|
|
304
|
+
return this.request<T>(path, { ...options, method: 'PATCH', body });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* DELETE request
|
|
309
|
+
*/
|
|
310
|
+
async delete<T = any>(path: string, options?: Omit<RequestOptions, 'method' | 'body'>): Promise<HttpResponse<T>> {
|
|
311
|
+
return this.request<T>(path, { ...options, method: 'DELETE' });
|
|
312
|
+
}
|
|
313
|
+
}
|
package/utils/index.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ListResponse, PageInfo } from '../types/common';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pagination helper utilities
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Async iterator for automatic pagination
|
|
9
|
+
*/
|
|
10
|
+
export class PaginatedIterator<T> {
|
|
11
|
+
private fetchPage: (cursor?: string) => Promise<ListResponse<T>>;
|
|
12
|
+
private currentPage: T[] = [];
|
|
13
|
+
private currentIndex = 0;
|
|
14
|
+
private nextCursor?: string;
|
|
15
|
+
private hasMore = true;
|
|
16
|
+
private isFirstFetch = true;
|
|
17
|
+
|
|
18
|
+
constructor(fetchPage: (cursor?: string) => Promise<ListResponse<T>>) {
|
|
19
|
+
this.fetchPage = fetchPage;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Async iterator implementation
|
|
24
|
+
*/
|
|
25
|
+
async *[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
26
|
+
while (this.hasMore || this.currentIndex < this.currentPage.length) {
|
|
27
|
+
// Fetch next page if current page is exhausted
|
|
28
|
+
if (this.currentIndex >= this.currentPage.length) {
|
|
29
|
+
if (!this.hasMore) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const response = await this.fetchPage(this.isFirstFetch ? undefined : this.nextCursor);
|
|
34
|
+
this.isFirstFetch = false;
|
|
35
|
+
this.currentPage = response.data;
|
|
36
|
+
this.currentIndex = 0;
|
|
37
|
+
this.nextCursor = response.pageInfo.nextCursor;
|
|
38
|
+
this.hasMore = response.pageInfo.hasMore;
|
|
39
|
+
|
|
40
|
+
// If no items in this page, stop iteration
|
|
41
|
+
if (this.currentPage.length === 0) {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Yield current item and advance index
|
|
47
|
+
yield this.currentPage[this.currentIndex++];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get all items from all pages (be careful with large datasets!)
|
|
53
|
+
*/
|
|
54
|
+
async toArray(): Promise<T[]> {
|
|
55
|
+
const items: T[] = [];
|
|
56
|
+
for await (const item of this) {
|
|
57
|
+
items.push(item);
|
|
58
|
+
}
|
|
59
|
+
return items;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create a paginated iterator
|
|
65
|
+
*/
|
|
66
|
+
export function createPaginatedIterator<T>(
|
|
67
|
+
fetchPage: (cursor?: string) => Promise<ListResponse<T>>
|
|
68
|
+
): PaginatedIterator<T> {
|
|
69
|
+
return new PaginatedIterator(fetchPage);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Helper to fetch all pages at once
|
|
74
|
+
*/
|
|
75
|
+
export async function fetchAllPages<T>(
|
|
76
|
+
fetchPage: (cursor?: string) => Promise<ListResponse<T>>,
|
|
77
|
+
maxPages?: number
|
|
78
|
+
): Promise<T[]> {
|
|
79
|
+
const allItems: T[] = [];
|
|
80
|
+
let cursor: string | undefined;
|
|
81
|
+
let hasMore = true;
|
|
82
|
+
let pageCount = 0;
|
|
83
|
+
|
|
84
|
+
while (hasMore && (!maxPages || pageCount < maxPages)) {
|
|
85
|
+
const response = await fetchPage(cursor);
|
|
86
|
+
allItems.push(...response.data);
|
|
87
|
+
|
|
88
|
+
cursor = response.pageInfo.nextCursor;
|
|
89
|
+
hasMore = response.pageInfo.hasMore;
|
|
90
|
+
pageCount++;
|
|
91
|
+
|
|
92
|
+
// Safety check
|
|
93
|
+
if (!hasMore || !cursor) {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return allItems;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Helper to check if there are more pages
|
|
103
|
+
*/
|
|
104
|
+
export function hasMorePages(pageInfo: PageInfo): boolean {
|
|
105
|
+
return pageInfo.hasMore && !!pageInfo.nextCursor;
|
|
106
|
+
}
|