@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/README.md +478 -113
- package/dist/__tests__/utils/mockClient.d.ts +65 -3
- package/dist/__tests__/utils/mockClient.js +144 -5
- package/dist/errors.d.ts +123 -0
- package/dist/errors.js +163 -0
- package/dist/http-client.d.ts +167 -0
- package/dist/http-client.js +250 -0
- package/dist/index.d.ts +160 -10
- package/dist/index.js +585 -146
- package/dist/types.d.ts +372 -50
- package/dist/types.js +21 -0
- package/package.json +1 -1
- package/src/__tests__/property/request-construction.property.test.ts +142 -91
- package/src/__tests__/property/response-parsing.property.test.ts +118 -67
- package/src/__tests__/property/sdk-modules.property.test.ts +450 -0
- package/src/__tests__/unit/aha.test.ts +61 -50
- package/src/__tests__/unit/badges.test.ts +27 -33
- package/src/__tests__/unit/config.test.ts +94 -126
- package/src/__tests__/unit/errors.test.ts +106 -150
- package/src/__tests__/unit/events.test.ts +119 -144
- package/src/__tests__/unit/leaderboards.test.ts +173 -40
- package/src/__tests__/unit/levels.test.ts +25 -33
- package/src/__tests__/unit/questionnaires.test.ts +33 -42
- package/src/__tests__/unit/users.test.ts +214 -99
- package/src/__tests__/utils/mockClient.ts +193 -6
- package/src/errors.ts +255 -0
- package/src/http-client.ts +433 -0
- package/src/index.ts +742 -150
- package/src/types.ts +429 -51
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
|
+
}
|