@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.
Files changed (28) hide show
  1. package/README.md +342 -141
  2. package/package.json +1 -1
  3. package/src/__tests__/fixtures/responses.js +249 -0
  4. package/src/__tests__/property/batch-event-validation.property.test.js +225 -0
  5. package/src/__tests__/property/email-validation.property.test.js +272 -0
  6. package/src/__tests__/property/error-mapping.property.test.js +506 -0
  7. package/src/__tests__/property/field-selection.property.test.js +297 -0
  8. package/src/__tests__/property/idempotency-key.property.test.js +350 -0
  9. package/src/__tests__/property/leaderboard-filter.property.test.js +585 -0
  10. package/src/__tests__/property/partial-update.property.test.js +251 -0
  11. package/src/__tests__/property/rate-limit-error.property.test.js +276 -0
  12. package/src/__tests__/property/rate-limit-extraction.property.test.js +193 -0
  13. package/src/__tests__/property/request-construction.property.test.js +20 -28
  14. package/src/__tests__/property/response-format.property.test.js +418 -0
  15. package/src/__tests__/property/response-parsing.property.test.js +16 -21
  16. package/src/__tests__/property/timestamp-validation.property.test.js +345 -0
  17. package/src/__tests__/unit/aha.test.js +57 -26
  18. package/src/__tests__/unit/config.test.js +7 -1
  19. package/src/__tests__/unit/errors.test.js +6 -8
  20. package/src/__tests__/unit/events.test.js +253 -14
  21. package/src/__tests__/unit/leaderboards.test.js +249 -0
  22. package/src/__tests__/unit/questionnaires.test.js +6 -6
  23. package/src/__tests__/unit/users.test.js +275 -12
  24. package/src/__tests__/utils/generators.js +87 -0
  25. package/src/__tests__/utils/mockClient.js +71 -5
  26. package/src/errors.js +156 -0
  27. package/src/http-client.js +276 -0
  28. 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
+ }