@rooguys/js 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 +342 -141
- package/package.json +1 -1
- package/src/__tests__/fixtures/responses.js +249 -0
- package/src/__tests__/property/batch-event-validation.property.test.js +225 -0
- package/src/__tests__/property/email-validation.property.test.js +272 -0
- package/src/__tests__/property/error-mapping.property.test.js +506 -0
- package/src/__tests__/property/field-selection.property.test.js +297 -0
- package/src/__tests__/property/idempotency-key.property.test.js +350 -0
- package/src/__tests__/property/leaderboard-filter.property.test.js +585 -0
- package/src/__tests__/property/partial-update.property.test.js +251 -0
- package/src/__tests__/property/rate-limit-error.property.test.js +276 -0
- package/src/__tests__/property/rate-limit-extraction.property.test.js +193 -0
- package/src/__tests__/property/request-construction.property.test.js +20 -28
- package/src/__tests__/property/response-format.property.test.js +418 -0
- package/src/__tests__/property/response-parsing.property.test.js +16 -21
- package/src/__tests__/property/timestamp-validation.property.test.js +345 -0
- package/src/__tests__/unit/aha.test.js +57 -26
- package/src/__tests__/unit/config.test.js +7 -1
- package/src/__tests__/unit/errors.test.js +6 -8
- package/src/__tests__/unit/events.test.js +253 -14
- package/src/__tests__/unit/leaderboards.test.js +249 -0
- package/src/__tests__/unit/questionnaires.test.js +6 -6
- package/src/__tests__/unit/users.test.js +275 -12
- package/src/__tests__/utils/generators.js +87 -0
- package/src/__tests__/utils/mockClient.js +71 -5
- package/src/errors.js +156 -0
- package/src/http-client.js +276 -0
- package/src/index.js +856 -66
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rooguys SDK HTTP Client
|
|
3
|
+
* Handles standardized response format, rate limit headers, and error mapping
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mapStatusToError, RooguysError, RateLimitError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rate limit information extracted from response headers
|
|
10
|
+
* @typedef {Object} RateLimitInfo
|
|
11
|
+
* @property {number} limit - Maximum requests allowed
|
|
12
|
+
* @property {number} remaining - Remaining requests in window
|
|
13
|
+
* @property {number} reset - Unix timestamp when limit resets
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* API response wrapper with metadata
|
|
18
|
+
* @typedef {Object} ApiResponse
|
|
19
|
+
* @property {*} data - Response data
|
|
20
|
+
* @property {string} requestId - Request ID for debugging
|
|
21
|
+
* @property {RateLimitInfo} rateLimit - Rate limit information
|
|
22
|
+
* @property {Object} [pagination] - Pagination info if present
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract rate limit information from response headers
|
|
27
|
+
* @param {Headers|Object} headers - Response headers
|
|
28
|
+
* @returns {RateLimitInfo} Rate limit info
|
|
29
|
+
*/
|
|
30
|
+
export function extractRateLimitInfo(headers) {
|
|
31
|
+
// Handle both Headers object and plain object
|
|
32
|
+
const getHeader = (name) => {
|
|
33
|
+
if (typeof headers.get === 'function') {
|
|
34
|
+
return headers.get(name);
|
|
35
|
+
}
|
|
36
|
+
return headers[name] || headers[name.toLowerCase()];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
limit: parseInt(getHeader('X-RateLimit-Limit') || getHeader('x-ratelimit-limit') || '1000', 10),
|
|
41
|
+
remaining: parseInt(getHeader('X-RateLimit-Remaining') || getHeader('x-ratelimit-remaining') || '1000', 10),
|
|
42
|
+
reset: parseInt(getHeader('X-RateLimit-Reset') || getHeader('x-ratelimit-reset') || '0', 10),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract request ID from response headers or body
|
|
48
|
+
* @param {Headers|Object} headers - Response headers
|
|
49
|
+
* @param {Object} body - Response body
|
|
50
|
+
* @returns {string|null} Request ID
|
|
51
|
+
*/
|
|
52
|
+
export function extractRequestId(headers, body) {
|
|
53
|
+
// Try headers first
|
|
54
|
+
const getHeader = (name) => {
|
|
55
|
+
if (typeof headers.get === 'function') {
|
|
56
|
+
return headers.get(name);
|
|
57
|
+
}
|
|
58
|
+
return headers[name] || headers[name.toLowerCase()];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const headerRequestId = getHeader('X-Request-Id') || getHeader('x-request-id');
|
|
62
|
+
if (headerRequestId) {
|
|
63
|
+
return headerRequestId;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fall back to body
|
|
67
|
+
return body?.request_id || body?.requestId || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse standardized API response format
|
|
72
|
+
* Handles both new format { success: true, data: {...} } and legacy format
|
|
73
|
+
* @param {Object} body - Response body
|
|
74
|
+
* @returns {Object} Parsed response with data and metadata
|
|
75
|
+
*/
|
|
76
|
+
export function parseResponseBody(body) {
|
|
77
|
+
// New standardized format
|
|
78
|
+
if (body && typeof body.success === 'boolean') {
|
|
79
|
+
if (body.success) {
|
|
80
|
+
return {
|
|
81
|
+
data: body.data,
|
|
82
|
+
pagination: body.pagination || null,
|
|
83
|
+
requestId: body.request_id || null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// Error response in standardized format
|
|
87
|
+
return {
|
|
88
|
+
error: body.error,
|
|
89
|
+
requestId: body.request_id || null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Legacy format - return as-is
|
|
94
|
+
return {
|
|
95
|
+
data: body,
|
|
96
|
+
pagination: body?.pagination || null,
|
|
97
|
+
requestId: null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* HTTP Client class for making API requests
|
|
103
|
+
*/
|
|
104
|
+
export class HttpClient {
|
|
105
|
+
constructor(apiKey, options = {}) {
|
|
106
|
+
this.apiKey = apiKey;
|
|
107
|
+
this.baseUrl = options.baseUrl || 'https://api.rooguys.com/v1';
|
|
108
|
+
this.timeout = options.timeout || 10000;
|
|
109
|
+
this.defaultHeaders = {
|
|
110
|
+
'x-api-key': apiKey,
|
|
111
|
+
'Content-Type': 'application/json',
|
|
112
|
+
};
|
|
113
|
+
this.onRateLimitWarning = options.onRateLimitWarning || null;
|
|
114
|
+
|
|
115
|
+
// Auto-retry configuration for rate-limited requests
|
|
116
|
+
this.autoRetry = options.autoRetry || false;
|
|
117
|
+
this.maxRetries = options.maxRetries || 3;
|
|
118
|
+
this.retryDelay = options.retryDelay || 1000; // Base delay in ms
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Sleep for a specified duration
|
|
123
|
+
* @param {number} ms - Milliseconds to sleep
|
|
124
|
+
* @returns {Promise<void>}
|
|
125
|
+
*/
|
|
126
|
+
_sleep(ms) {
|
|
127
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Make an HTTP request with optional auto-retry for rate limits
|
|
132
|
+
* @param {Object} config - Request configuration
|
|
133
|
+
* @param {string} config.method - HTTP method
|
|
134
|
+
* @param {string} config.path - API endpoint path
|
|
135
|
+
* @param {Object} [config.params] - Query parameters
|
|
136
|
+
* @param {Object} [config.body] - Request body
|
|
137
|
+
* @param {Object} [config.headers] - Additional headers
|
|
138
|
+
* @param {string} [config.idempotencyKey] - Idempotency key for POST requests
|
|
139
|
+
* @param {number} [retryCount=0] - Current retry attempt (internal use)
|
|
140
|
+
* @returns {Promise<ApiResponse>} API response with data and metadata
|
|
141
|
+
*/
|
|
142
|
+
async request(config, retryCount = 0) {
|
|
143
|
+
const { method = 'GET', path, params = {}, body = null, headers = {}, idempotencyKey = null } = config;
|
|
144
|
+
|
|
145
|
+
// Build URL with query parameters
|
|
146
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
147
|
+
Object.keys(params).forEach(key => {
|
|
148
|
+
if (params[key] !== undefined && params[key] !== null) {
|
|
149
|
+
url.searchParams.append(key, params[key]);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Build request headers
|
|
154
|
+
const requestHeaders = {
|
|
155
|
+
...this.defaultHeaders,
|
|
156
|
+
...headers,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Add idempotency key if provided
|
|
160
|
+
if (idempotencyKey) {
|
|
161
|
+
requestHeaders['X-Idempotency-Key'] = idempotencyKey;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Build fetch config
|
|
165
|
+
const fetchConfig = {
|
|
166
|
+
method,
|
|
167
|
+
headers: requestHeaders,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (body) {
|
|
171
|
+
fetchConfig.body = JSON.stringify(body);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
// Setup timeout
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
178
|
+
fetchConfig.signal = controller.signal;
|
|
179
|
+
|
|
180
|
+
// Make request
|
|
181
|
+
const response = await fetch(url.toString(), fetchConfig);
|
|
182
|
+
clearTimeout(timeoutId);
|
|
183
|
+
|
|
184
|
+
// Extract headers info
|
|
185
|
+
const rateLimit = extractRateLimitInfo(response.headers);
|
|
186
|
+
|
|
187
|
+
// Check for rate limit warning (80% consumed)
|
|
188
|
+
if (rateLimit.remaining < rateLimit.limit * 0.2 && this.onRateLimitWarning) {
|
|
189
|
+
this.onRateLimitWarning(rateLimit);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Parse response body
|
|
193
|
+
const responseBody = await response.json().catch(() => ({}));
|
|
194
|
+
const requestId = extractRequestId(response.headers, responseBody);
|
|
195
|
+
|
|
196
|
+
// Handle error responses
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
const error = mapStatusToError(response.status, responseBody, requestId, {
|
|
199
|
+
'retry-after': response.headers.get('Retry-After'),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Auto-retry for rate limit errors if enabled
|
|
203
|
+
if (this.autoRetry && error instanceof RateLimitError && retryCount < this.maxRetries) {
|
|
204
|
+
const retryAfterMs = error.retryAfter * 1000;
|
|
205
|
+
await this._sleep(retryAfterMs);
|
|
206
|
+
return this.request(config, retryCount + 1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Parse successful response
|
|
213
|
+
const parsed = parseResponseBody(responseBody);
|
|
214
|
+
|
|
215
|
+
// Check for error in standardized format
|
|
216
|
+
if (parsed.error) {
|
|
217
|
+
throw mapStatusToError(400, { error: parsed.error }, requestId, {});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
data: parsed.data,
|
|
222
|
+
requestId: requestId || parsed.requestId,
|
|
223
|
+
rateLimit,
|
|
224
|
+
pagination: parsed.pagination,
|
|
225
|
+
};
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// Re-throw RooguysError instances
|
|
228
|
+
if (error instanceof RooguysError) {
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Handle abort/timeout
|
|
233
|
+
if (error.name === 'AbortError') {
|
|
234
|
+
throw new RooguysError('Request timeout', { code: 'TIMEOUT', statusCode: 408 });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Handle network errors
|
|
238
|
+
throw new RooguysError(error.message || 'Network error', { code: 'NETWORK_ERROR', statusCode: 0 });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Convenience method for GET requests
|
|
244
|
+
*/
|
|
245
|
+
get(path, params = {}, options = {}) {
|
|
246
|
+
return this.request({ method: 'GET', path, params, ...options });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Convenience method for POST requests
|
|
251
|
+
*/
|
|
252
|
+
post(path, body = null, options = {}) {
|
|
253
|
+
return this.request({ method: 'POST', path, body, ...options });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Convenience method for PUT requests
|
|
258
|
+
*/
|
|
259
|
+
put(path, body = null, options = {}) {
|
|
260
|
+
return this.request({ method: 'PUT', path, body, ...options });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Convenience method for PATCH requests
|
|
265
|
+
*/
|
|
266
|
+
patch(path, body = null, options = {}) {
|
|
267
|
+
return this.request({ method: 'PATCH', path, body, ...options });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Convenience method for DELETE requests
|
|
272
|
+
*/
|
|
273
|
+
delete(path, options = {}) {
|
|
274
|
+
return this.request({ method: 'DELETE', path, ...options });
|
|
275
|
+
}
|
|
276
|
+
}
|