@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.
@@ -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
+ }
@@ -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,8 @@
1
+ /**
2
+ * Export utility functions and classes
3
+ */
4
+
5
+ export * from './errors';
6
+ export * from './units';
7
+ export * from './http';
8
+ export * from './pagination';
@@ -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
+ }