@rooguys/sdk 0.1.0 → 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/src/errors.ts ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Rooguys Node.js SDK Error Classes
3
+ * Typed error classes for different API error scenarios
4
+ */
5
+
6
+ /**
7
+ * Field-level error detail
8
+ */
9
+ export interface FieldError {
10
+ field: string;
11
+ message: string;
12
+ }
13
+
14
+ /**
15
+ * Base error options
16
+ */
17
+ export interface RooguysErrorOptions {
18
+ code?: string;
19
+ requestId?: string | null;
20
+ statusCode?: number;
21
+ }
22
+
23
+ /**
24
+ * Base error class for all Rooguys SDK errors
25
+ */
26
+ export class RooguysError extends Error {
27
+ public readonly code: string;
28
+ public readonly requestId: string | null;
29
+ public readonly statusCode: number;
30
+
31
+ constructor(
32
+ message: string,
33
+ { code = 'UNKNOWN_ERROR', requestId = null, statusCode = 500 }: RooguysErrorOptions = {}
34
+ ) {
35
+ super(message);
36
+ this.name = 'RooguysError';
37
+ this.code = code;
38
+ this.requestId = requestId;
39
+ this.statusCode = statusCode;
40
+
41
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
42
+ if (Error.captureStackTrace) {
43
+ Error.captureStackTrace(this, this.constructor);
44
+ }
45
+ }
46
+
47
+ toJSON(): Record<string, unknown> {
48
+ return {
49
+ name: this.name,
50
+ message: this.message,
51
+ code: this.code,
52
+ requestId: this.requestId,
53
+ statusCode: this.statusCode,
54
+ };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Validation error options
60
+ */
61
+ export interface ValidationErrorOptions extends RooguysErrorOptions {
62
+ fieldErrors?: FieldError[] | null;
63
+ }
64
+
65
+ /**
66
+ * Validation error (HTTP 400)
67
+ * Thrown when request validation fails
68
+ */
69
+ export class ValidationError extends RooguysError {
70
+ public readonly fieldErrors: FieldError[] | null;
71
+
72
+ constructor(
73
+ message: string,
74
+ { code = 'VALIDATION_ERROR', requestId = null, fieldErrors = null }: ValidationErrorOptions = {}
75
+ ) {
76
+ super(message, { code, requestId, statusCode: 400 });
77
+ this.name = 'ValidationError';
78
+ this.fieldErrors = fieldErrors;
79
+ }
80
+
81
+ toJSON(): Record<string, unknown> {
82
+ return {
83
+ ...super.toJSON(),
84
+ fieldErrors: this.fieldErrors,
85
+ };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Authentication error (HTTP 401)
91
+ * Thrown when API key is invalid or missing
92
+ */
93
+ export class AuthenticationError extends RooguysError {
94
+ constructor(
95
+ message: string,
96
+ { code = 'AUTHENTICATION_ERROR', requestId = null }: RooguysErrorOptions = {}
97
+ ) {
98
+ super(message, { code, requestId, statusCode: 401 });
99
+ this.name = 'AuthenticationError';
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Forbidden error (HTTP 403)
105
+ * Thrown when access is denied
106
+ */
107
+ export class ForbiddenError extends RooguysError {
108
+ constructor(
109
+ message: string,
110
+ { code = 'FORBIDDEN', requestId = null }: RooguysErrorOptions = {}
111
+ ) {
112
+ super(message, { code, requestId, statusCode: 403 });
113
+ this.name = 'ForbiddenError';
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Not found error (HTTP 404)
119
+ * Thrown when requested resource doesn't exist
120
+ */
121
+ export class NotFoundError extends RooguysError {
122
+ constructor(
123
+ message: string,
124
+ { code = 'NOT_FOUND', requestId = null }: RooguysErrorOptions = {}
125
+ ) {
126
+ super(message, { code, requestId, statusCode: 404 });
127
+ this.name = 'NotFoundError';
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Conflict error (HTTP 409)
133
+ * Thrown when resource already exists or state conflict
134
+ */
135
+ export class ConflictError extends RooguysError {
136
+ constructor(
137
+ message: string,
138
+ { code = 'CONFLICT', requestId = null }: RooguysErrorOptions = {}
139
+ ) {
140
+ super(message, { code, requestId, statusCode: 409 });
141
+ this.name = 'ConflictError';
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Rate limit error options
147
+ */
148
+ export interface RateLimitErrorOptions extends RooguysErrorOptions {
149
+ retryAfter?: number;
150
+ }
151
+
152
+ /**
153
+ * Rate limit error (HTTP 429)
154
+ * Thrown when rate limit is exceeded
155
+ */
156
+ export class RateLimitError extends RooguysError {
157
+ public readonly retryAfter: number;
158
+
159
+ constructor(
160
+ message: string,
161
+ { code = 'RATE_LIMIT_EXCEEDED', requestId = null, retryAfter = 60 }: RateLimitErrorOptions = {}
162
+ ) {
163
+ super(message, { code, requestId, statusCode: 429 });
164
+ this.name = 'RateLimitError';
165
+ this.retryAfter = retryAfter;
166
+ }
167
+
168
+ toJSON(): Record<string, unknown> {
169
+ return {
170
+ ...super.toJSON(),
171
+ retryAfter: this.retryAfter,
172
+ };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Server error (HTTP 500+)
178
+ * Thrown when server encounters an error
179
+ */
180
+ export class ServerError extends RooguysError {
181
+ constructor(
182
+ message: string,
183
+ { code = 'SERVER_ERROR', requestId = null, statusCode = 500 }: RooguysErrorOptions = {}
184
+ ) {
185
+ super(message, { code, requestId, statusCode });
186
+ this.name = 'ServerError';
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Error response body structure
192
+ */
193
+ export interface ErrorResponseBody {
194
+ error?: {
195
+ message?: string;
196
+ code?: string;
197
+ details?: FieldError[];
198
+ } | string;
199
+ message?: string;
200
+ code?: string;
201
+ details?: FieldError[];
202
+ }
203
+
204
+ /**
205
+ * Response headers for error mapping
206
+ */
207
+ export interface ErrorResponseHeaders {
208
+ 'retry-after'?: string;
209
+ 'Retry-After'?: string;
210
+ }
211
+
212
+ /**
213
+ * Map HTTP status code to appropriate error class
214
+ * @param status - HTTP status code
215
+ * @param errorBody - Error response body
216
+ * @param requestId - Request ID from response
217
+ * @param headers - Response headers
218
+ * @returns Appropriate error instance
219
+ */
220
+ export function mapStatusToError(
221
+ status: number,
222
+ errorBody: ErrorResponseBody | null,
223
+ requestId: string | null,
224
+ headers: ErrorResponseHeaders = {}
225
+ ): RooguysError {
226
+ const errorObj = typeof errorBody?.error === 'object' ? errorBody.error : null;
227
+ const message = errorObj?.message ||
228
+ (typeof errorBody?.error === 'string' ? errorBody.error : null) ||
229
+ errorBody?.message ||
230
+ 'An error occurred';
231
+ const code = errorObj?.code || errorBody?.code || 'UNKNOWN_ERROR';
232
+ const fieldErrors = errorObj?.details || errorBody?.details || null;
233
+
234
+ switch (status) {
235
+ case 400:
236
+ return new ValidationError(message, { code, requestId, fieldErrors });
237
+ case 401:
238
+ return new AuthenticationError(message, { code, requestId });
239
+ case 403:
240
+ return new ForbiddenError(message, { code, requestId });
241
+ case 404:
242
+ return new NotFoundError(message, { code, requestId });
243
+ case 409:
244
+ return new ConflictError(message, { code, requestId });
245
+ case 429: {
246
+ const retryAfter = parseInt(headers['retry-after'] || headers['Retry-After'] || '60', 10);
247
+ return new RateLimitError(message, { code, requestId, retryAfter });
248
+ }
249
+ default:
250
+ if (status >= 500) {
251
+ return new ServerError(message, { code, requestId, statusCode: status });
252
+ }
253
+ return new RooguysError(message, { code, requestId, statusCode: status });
254
+ }
255
+ }
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Rooguys Node.js SDK HTTP Client
3
+ * Handles standardized response format, rate limit headers, and error mapping
4
+ */
5
+
6
+ import axios, { AxiosInstance, AxiosError, AxiosResponse, AxiosRequestConfig } from 'axios';
7
+ import {
8
+ RooguysError,
9
+ RateLimitError,
10
+ mapStatusToError,
11
+ ErrorResponseBody,
12
+ } from './errors';
13
+
14
+ /**
15
+ * Rate limit information extracted from response headers
16
+ */
17
+ export interface RateLimitInfo {
18
+ /** Maximum requests allowed in the window */
19
+ limit: number;
20
+ /** Remaining requests in the current window */
21
+ remaining: number;
22
+ /** Unix timestamp when the limit resets */
23
+ reset: number;
24
+ }
25
+
26
+ /**
27
+ * Cache metadata from API responses
28
+ */
29
+ export interface CacheMetadata {
30
+ /** When the data was cached */
31
+ cachedAt: Date | null;
32
+ /** Time-to-live in seconds */
33
+ ttl: number;
34
+ }
35
+
36
+ /**
37
+ * Pagination information
38
+ */
39
+ export interface Pagination {
40
+ page: number;
41
+ limit: number;
42
+ total: number;
43
+ totalPages: number;
44
+ }
45
+
46
+ /**
47
+ * API response wrapper with metadata
48
+ */
49
+ export interface ApiResponse<T> {
50
+ /** Response data */
51
+ data: T;
52
+ /** Request ID for debugging */
53
+ requestId: string | null;
54
+ /** Rate limit information */
55
+ rateLimit: RateLimitInfo;
56
+ /** Pagination info if present */
57
+ pagination?: Pagination | null;
58
+ /** Cache metadata if present */
59
+ cacheMetadata?: CacheMetadata | null;
60
+ }
61
+
62
+ /**
63
+ * Request configuration options
64
+ */
65
+ export interface RequestConfig {
66
+ /** HTTP method */
67
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
68
+ /** API endpoint path */
69
+ path: string;
70
+ /** Query parameters */
71
+ params?: Record<string, string | number | boolean | undefined | null>;
72
+ /** Request body */
73
+ body?: unknown;
74
+ /** Additional headers */
75
+ headers?: Record<string, string>;
76
+ /** Idempotency key for POST requests */
77
+ idempotencyKey?: string;
78
+ /** Request timeout in ms */
79
+ timeout?: number;
80
+ }
81
+
82
+ /**
83
+ * HTTP Client options
84
+ */
85
+ export interface HttpClientOptions {
86
+ /** Base URL for API */
87
+ baseUrl?: string;
88
+ /** Request timeout in ms */
89
+ timeout?: number;
90
+ /** Callback when rate limit is 80% consumed */
91
+ onRateLimitWarning?: ((rateLimit: RateLimitInfo) => void) | null;
92
+ /** Enable auto-retry for rate-limited requests */
93
+ autoRetry?: boolean;
94
+ /** Maximum retry attempts for rate limits */
95
+ maxRetries?: number;
96
+ /** Base delay for retries in ms */
97
+ retryDelay?: number;
98
+ }
99
+
100
+ /**
101
+ * Standardized API response format
102
+ */
103
+ interface StandardizedResponse<T> {
104
+ success: boolean;
105
+ data?: T;
106
+ error?: {
107
+ code?: string;
108
+ message?: string;
109
+ details?: Array<{ field: string; message: string }>;
110
+ };
111
+ request_id?: string;
112
+ pagination?: Pagination;
113
+ }
114
+
115
+ /**
116
+ * Parsed response body result
117
+ */
118
+ interface ParsedResponseBody<T> {
119
+ data?: T;
120
+ error?: ErrorResponseBody['error'];
121
+ pagination?: Pagination | null;
122
+ requestId?: string | null;
123
+ }
124
+
125
+ /**
126
+ * Extract rate limit information from response headers
127
+ * @param headers - Response headers (axios format)
128
+ * @returns Rate limit info
129
+ */
130
+ export function extractRateLimitInfo(headers: Record<string, string | undefined>): RateLimitInfo {
131
+ const getHeader = (name: string): string | undefined => {
132
+ return headers[name] || headers[name.toLowerCase()];
133
+ };
134
+
135
+ return {
136
+ limit: parseInt(getHeader('X-RateLimit-Limit') || getHeader('x-ratelimit-limit') || '1000', 10),
137
+ remaining: parseInt(getHeader('X-RateLimit-Remaining') || getHeader('x-ratelimit-remaining') || '1000', 10),
138
+ reset: parseInt(getHeader('X-RateLimit-Reset') || getHeader('x-ratelimit-reset') || '0', 10),
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Extract request ID from response headers or body
144
+ * @param headers - Response headers
145
+ * @param body - Response body
146
+ * @returns Request ID or null
147
+ */
148
+ export function extractRequestId(
149
+ headers: Record<string, string | undefined>,
150
+ body: unknown
151
+ ): string | null {
152
+ // Try headers first
153
+ const getHeader = (name: string): string | undefined => {
154
+ return headers[name] || headers[name.toLowerCase()];
155
+ };
156
+
157
+ const headerRequestId = getHeader('X-Request-Id') || getHeader('x-request-id');
158
+ if (headerRequestId) {
159
+ return headerRequestId;
160
+ }
161
+
162
+ // Fall back to body
163
+ if (body && typeof body === 'object') {
164
+ const bodyObj = body as Record<string, unknown>;
165
+ return (bodyObj.request_id as string) || (bodyObj.requestId as string) || null;
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * Parse standardized API response format
173
+ * Handles both new format { success: true, data: {...} } and legacy format
174
+ * @param body - Response body
175
+ * @returns Parsed response with data and metadata
176
+ */
177
+ export function parseResponseBody<T>(body: unknown): ParsedResponseBody<T> {
178
+ if (!body || typeof body !== 'object') {
179
+ return {
180
+ data: body as T,
181
+ pagination: null,
182
+ requestId: null,
183
+ };
184
+ }
185
+
186
+ const bodyObj = body as Record<string, unknown>;
187
+
188
+ // New standardized format with { success: true, data: {...} }
189
+ if (typeof bodyObj.success === 'boolean') {
190
+ if (bodyObj.success) {
191
+ // If there's a data field, unwrap it; otherwise return the whole body
192
+ // This handles both { success: true, data: {...} } and { success: true, message: "..." }
193
+ const data = 'data' in bodyObj ? bodyObj.data : body;
194
+ return {
195
+ data: data as T,
196
+ pagination: (bodyObj.pagination as Pagination) || null,
197
+ requestId: (bodyObj.request_id as string) || null,
198
+ };
199
+ }
200
+ // Error response in standardized format
201
+ return {
202
+ error: bodyObj.error as ErrorResponseBody['error'],
203
+ requestId: (bodyObj.request_id as string) || null,
204
+ };
205
+ }
206
+
207
+ // Legacy format - return as-is
208
+ return {
209
+ data: body as T,
210
+ pagination: (bodyObj.pagination as Pagination) || null,
211
+ requestId: null,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * HTTP Client class for making API requests
217
+ */
218
+ export class HttpClient {
219
+ private client: AxiosInstance;
220
+ private apiKey: string;
221
+ private baseUrl: string;
222
+ private timeout: number;
223
+ private onRateLimitWarning: ((rateLimit: RateLimitInfo) => void) | null;
224
+ private autoRetry: boolean;
225
+ private maxRetries: number;
226
+ private retryDelay: number;
227
+
228
+ constructor(apiKey: string, options: HttpClientOptions = {}) {
229
+ this.apiKey = apiKey;
230
+ this.baseUrl = options.baseUrl || 'https://api.rooguys.com/v1';
231
+ this.timeout = options.timeout || 10000;
232
+ this.onRateLimitWarning = options.onRateLimitWarning || null;
233
+ this.autoRetry = options.autoRetry || false;
234
+ this.maxRetries = options.maxRetries || 3;
235
+ this.retryDelay = options.retryDelay || 1000;
236
+
237
+ this.client = axios.create({
238
+ baseURL: this.baseUrl,
239
+ timeout: this.timeout,
240
+ headers: {
241
+ 'x-api-key': this.apiKey,
242
+ 'Content-Type': 'application/json',
243
+ },
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Sleep for a specified duration
249
+ * @param ms - Milliseconds to sleep
250
+ */
251
+ private sleep(ms: number): Promise<void> {
252
+ return new Promise(resolve => setTimeout(resolve, ms));
253
+ }
254
+
255
+ /**
256
+ * Build query string from params object
257
+ * @param params - Query parameters
258
+ * @returns Cleaned params object
259
+ */
260
+ private buildParams(
261
+ params: Record<string, string | number | boolean | undefined | null>
262
+ ): Record<string, string | number | boolean> {
263
+ const cleaned: Record<string, string | number | boolean> = {};
264
+ for (const [key, value] of Object.entries(params)) {
265
+ if (value !== undefined && value !== null) {
266
+ cleaned[key] = value;
267
+ }
268
+ }
269
+ return cleaned;
270
+ }
271
+
272
+ /**
273
+ * Make an HTTP request with optional auto-retry for rate limits
274
+ * @param config - Request configuration
275
+ * @param retryCount - Current retry attempt (internal use)
276
+ * @returns API response with data and metadata
277
+ */
278
+ async request<T>(config: RequestConfig, retryCount = 0): Promise<ApiResponse<T>> {
279
+ const {
280
+ method = 'GET',
281
+ path,
282
+ params = {},
283
+ body = null,
284
+ headers = {},
285
+ idempotencyKey = undefined,
286
+ timeout,
287
+ } = config;
288
+
289
+ // Build request config
290
+ const requestConfig: AxiosRequestConfig = {
291
+ method,
292
+ url: path,
293
+ params: this.buildParams(params),
294
+ headers: { ...headers },
295
+ };
296
+
297
+ if (body !== null) {
298
+ requestConfig.data = body;
299
+ }
300
+
301
+ if (timeout) {
302
+ requestConfig.timeout = timeout;
303
+ }
304
+
305
+ // Add idempotency key if provided
306
+ if (idempotencyKey) {
307
+ requestConfig.headers = {
308
+ ...requestConfig.headers,
309
+ 'X-Idempotency-Key': idempotencyKey,
310
+ };
311
+ }
312
+
313
+ try {
314
+ const response: AxiosResponse = await this.client.request(requestConfig);
315
+
316
+ // Extract headers info
317
+ const rateLimit = extractRateLimitInfo(response.headers as Record<string, string>);
318
+
319
+ // Check for rate limit warning (80% consumed)
320
+ if (rateLimit.remaining < rateLimit.limit * 0.2 && this.onRateLimitWarning) {
321
+ this.onRateLimitWarning(rateLimit);
322
+ }
323
+
324
+ // Extract request ID
325
+ const requestId = extractRequestId(response.headers as Record<string, string>, response.data);
326
+
327
+ // Parse response body
328
+ const parsed = parseResponseBody<T>(response.data);
329
+
330
+ // Check for error in standardized format
331
+ if (parsed.error) {
332
+ throw mapStatusToError(400, { error: parsed.error }, requestId, {});
333
+ }
334
+
335
+ return {
336
+ data: parsed.data as T,
337
+ requestId: requestId || parsed.requestId || null,
338
+ rateLimit,
339
+ pagination: parsed.pagination,
340
+ };
341
+ } catch (error) {
342
+ // Re-throw RooguysError instances
343
+ if (error instanceof RooguysError) {
344
+ throw error;
345
+ }
346
+
347
+ // Handle Axios errors
348
+ if (axios.isAxiosError(error)) {
349
+ const axiosError = error as AxiosError<ErrorResponseBody>;
350
+ const status = axiosError.response?.status || 0;
351
+ const responseData = axiosError.response?.data || null;
352
+ const responseHeaders = (axiosError.response?.headers || {}) as Record<string, string>;
353
+ const requestId = extractRequestId(responseHeaders, responseData);
354
+
355
+ const mappedError = mapStatusToError(status, responseData, requestId, {
356
+ 'retry-after': responseHeaders['retry-after'],
357
+ 'Retry-After': responseHeaders['Retry-After'],
358
+ });
359
+
360
+ // Auto-retry for rate limit errors if enabled
361
+ if (this.autoRetry && mappedError instanceof RateLimitError && retryCount < this.maxRetries) {
362
+ const retryAfterMs = mappedError.retryAfter * 1000;
363
+ await this.sleep(retryAfterMs);
364
+ return this.request<T>(config, retryCount + 1);
365
+ }
366
+
367
+ throw mappedError;
368
+ }
369
+
370
+ // Handle timeout
371
+ if (error instanceof Error && error.message.includes('timeout')) {
372
+ throw new RooguysError('Request timeout', { code: 'TIMEOUT', statusCode: 408 });
373
+ }
374
+
375
+ // Handle other errors
376
+ throw new RooguysError(
377
+ error instanceof Error ? error.message : 'Network error',
378
+ { code: 'NETWORK_ERROR', statusCode: 0 }
379
+ );
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Convenience method for GET requests
385
+ */
386
+ get<T>(
387
+ path: string,
388
+ params: Record<string, string | number | boolean | undefined | null> = {},
389
+ options: Partial<RequestConfig> = {}
390
+ ): Promise<ApiResponse<T>> {
391
+ return this.request<T>({ method: 'GET', path, params, ...options });
392
+ }
393
+
394
+ /**
395
+ * Convenience method for POST requests
396
+ */
397
+ post<T>(
398
+ path: string,
399
+ body: unknown = null,
400
+ options: Partial<RequestConfig> = {}
401
+ ): Promise<ApiResponse<T>> {
402
+ return this.request<T>({ method: 'POST', path, body, ...options });
403
+ }
404
+
405
+ /**
406
+ * Convenience method for PUT requests
407
+ */
408
+ put<T>(
409
+ path: string,
410
+ body: unknown = null,
411
+ options: Partial<RequestConfig> = {}
412
+ ): Promise<ApiResponse<T>> {
413
+ return this.request<T>({ method: 'PUT', path, body, ...options });
414
+ }
415
+
416
+ /**
417
+ * Convenience method for PATCH requests
418
+ */
419
+ patch<T>(
420
+ path: string,
421
+ body: unknown = null,
422
+ options: Partial<RequestConfig> = {}
423
+ ): Promise<ApiResponse<T>> {
424
+ return this.request<T>({ method: 'PATCH', path, body, ...options });
425
+ }
426
+
427
+ /**
428
+ * Convenience method for DELETE requests
429
+ */
430
+ delete<T>(path: string, options: Partial<RequestConfig> = {}): Promise<ApiResponse<T>> {
431
+ return this.request<T>({ method: 'DELETE', path, ...options });
432
+ }
433
+ }