dupr-js-client 0.1.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 ADDED
@@ -0,0 +1,909 @@
1
+ # dupr-js-client
2
+
3
+ [![CI](https://github.com/sunnytambi/dupr-js-client/actions/workflows/ci.yml/badge.svg)](https://github.com/sunnytambi/dupr-js-client/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/dupr-js-client)](https://www.npmjs.com/package/dupr-js-client)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ ![Node.js 18+](https://img.shields.io/badge/node-%3E%3D18-brightgreen)
7
+ ![zero dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen)
8
+
9
+ A TypeScript/JavaScript client for the [DUPR (Dynamic Universal Pickleball Rating)](https://mydupr.com) Partner API.
10
+ Modelled with inspiration from the Python library [dupr-api-client](https://libraries.io/pypi/dupr-api-client).
11
+
12
+ **Source:** OpenAPI 3.1.0 spec at `https://uat.mydupr.com/api/v3/api-docs/DUPR%20Partner%20APIs`
13
+
14
+ ---
15
+
16
+ ## Features
17
+
18
+ - Full coverage of the DUPR Partner API v1.0
19
+ - Auto-refreshing client-credentials token cache — transparent, no action required
20
+ - Authorization Code Flow support for user-facing "Connect DUPR" integrations
21
+ - Automatic retry with exponential backoff on 5xx and network errors; honours `Retry-After` on 429
22
+ - Typed request/response bodies for every endpoint
23
+ - Structured error hierarchy (`AuthenticationError`, `ValidationError`, `NotFoundError`, `RateLimitError`, `ServerError`)
24
+ - Dual ESM + CJS build — works in Node.js, Bun, and bundlers
25
+ - Zero runtime dependencies — uses the native `fetch` API (Node.js 18+)
26
+ - `customFetch` injection for testing, polyfilling, or proxying
27
+ - `onRequest` / `onResponse` hooks for logging and metrics
28
+
29
+ ---
30
+
31
+ ## Table of Contents
32
+
33
+ 1. [Installation](#1-installation)
34
+ 2. [Quick Start](#2-quick-start)
35
+ 3. [Configuration](#3-configuration)
36
+ 4. [Authentication](#4-authentication)
37
+ - [Client Credentials](#41-client-credentials-server-to-server)
38
+ - [Static Bearer Token](#42-static-bearer-token)
39
+ - [Authorization Code Flow](#43-authorization-code-flow-connect-dupr-button)
40
+ 5. [Retry Behaviour](#5-retry-behaviour)
41
+ 6. [Error Handling](#6-error-handling)
42
+ 7. [Observability Hooks](#7-observability-hooks)
43
+ 8. [API Reference](#8-api-reference)
44
+ - [client.auth](#81-clientauth)
45
+ - [client.users](#82-clientusers)
46
+ - [client.players](#83-clientplayers)
47
+ - [client.playerRating](#84-clientplayerrating)
48
+ - [client.matches](#85-clientmatches)
49
+ - [client.clubs](#86-clientclubs)
50
+ - [client.events](#87-clientevents)
51
+ - [client.webhooks](#88-clientwebhooks)
52
+ 9. [Type Reference](#9-type-reference)
53
+ 10. [Testing](#10-testing)
54
+ 11. [Express Integration Example](#11-express-integration-example)
55
+ 12. [Regenerating Types from the OpenAPI Spec](#12-regenerating-types-from-the-openapi-spec)
56
+ 13. [Development](#13-development)
57
+ 14. [Contributing](#14-contributing)
58
+ 15. [License](#15-license)
59
+
60
+ ---
61
+
62
+ ## 1. Installation
63
+
64
+ ```bash
65
+ npm install dupr-js-client
66
+ ```
67
+
68
+ **Requirements:** Node.js 18+ (native `fetch` and `AbortSignal.timeout`), TypeScript 5.x (optional — types are bundled).
69
+
70
+ The package ships both ESM (`dist/index.js`) and CJS (`dist/index.cjs`) with full `.d.ts` declarations.
71
+
72
+ ---
73
+
74
+ ## 2. Quick Start
75
+
76
+ ```ts
77
+ import { DuprClient } from "dupr-js-client";
78
+
79
+ const client = new DuprClient({
80
+ auth: {
81
+ type: "clientCredentials",
82
+ clientKey: process.env.DUPR_CLIENT_KEY!,
83
+ clientSecret: process.env.DUPR_CLIENT_SECRET!,
84
+ },
85
+ // baseUrl defaults to "https://uat.mydupr.com/api"
86
+ // version defaults to "v1.0"
87
+ });
88
+
89
+ // Look up a player profile
90
+ const { result } = await client.users.getUser("ABC123");
91
+ console.log(result?.fullName, result?.doublesRating);
92
+
93
+ // Search players by name with filters
94
+ const found = await client.users.search({
95
+ query: "Jane Smith",
96
+ offset: 0,
97
+ limit: 10,
98
+ filters: { rating: { type: "DOUBLES", min: 4.0, max: 5.5 } },
99
+ });
100
+
101
+ // Submit a doubles match result
102
+ const match = await client.matches.create({
103
+ identifier: "my-app-match-001", // your universally unique ID — never reuse
104
+ matchDate: "2024-06-15", // yyyy-MM-dd
105
+ matchFormat: "DOUBLES",
106
+ source: "PARTNER",
107
+ teams: [
108
+ { players: [{ duprId: "AAA111" }, { duprId: "BBB222" }], scores: [11, 8] },
109
+ { players: [{ duprId: "CCC333" }, { duprId: "DDD444" }], scores: [8, 11] },
110
+ ],
111
+ });
112
+ console.log("Match code:", match.result?.matchCode);
113
+ ```
114
+
115
+ ---
116
+
117
+ ## 3. Configuration
118
+
119
+ ```ts
120
+ const client = new DuprClient(options);
121
+ ```
122
+
123
+ ### `DuprClientOptions`
124
+
125
+ | Option | Type | Default | Description |
126
+ |---|---|---|---|
127
+ | `auth` | `AuthMode` | `{ type: "none" }` | Authentication mode. See [§4](#4-authentication). |
128
+ | `baseUrl` | `string` | `"https://uat.mydupr.com/api"` | API base URL. Use `"https://mydupr.com/api"` for production. |
129
+ | `version` | `string` | `"v1.0"` | API version prefix inserted in all paths. |
130
+ | `timeoutMs` | `number` | `30_000` | Per-request timeout in milliseconds. |
131
+ | `userAgent` | `string` | `"dupr-js-client/0.1.0"` | `User-Agent` header sent on every request. |
132
+ | `customFetch` | `typeof fetch` | `globalThis.fetch` | Inject a custom fetch — useful for mocking in tests or polyfilling. |
133
+ | `retry` | `RetryOptions \| false` | see below | Retry policy for transient errors. Pass `false` to disable. |
134
+ | `onRequest` | `(info: RequestInfo) => void` | — | Called before every request. Useful for logging. |
135
+ | `onResponse` | `(info: ResponseInfo) => void` | — | Called after every response. Useful for metrics. |
136
+
137
+ ### `RetryOptions`
138
+
139
+ | Option | Type | Default | Description |
140
+ |---|---|---|---|
141
+ | `maxRetries` | `number` | `3` | Maximum retry attempts after the initial failure. |
142
+ | `baseDelayMs` | `number` | `1_000` | Base backoff delay in ms. Doubles each attempt with ±20% jitter. |
143
+ | `maxDelayMs` | `number` | `30_000` | Maximum backoff cap in ms. |
144
+
145
+ ### Environment variables pattern
146
+
147
+ ```ts
148
+ // duprClient.ts
149
+ import { DuprClient } from "dupr-js-client";
150
+
151
+ export const dupr = new DuprClient({
152
+ baseUrl: process.env.DUPR_BASE_URL ?? "https://uat.mydupr.com/api",
153
+ auth: {
154
+ type: "clientCredentials",
155
+ clientKey: process.env.DUPR_CLIENT_KEY!,
156
+ clientSecret: process.env.DUPR_CLIENT_SECRET!,
157
+ },
158
+ });
159
+ ```
160
+
161
+ ```ini
162
+ # .env
163
+ DUPR_BASE_URL=https://uat.mydupr.com/api
164
+ DUPR_CLIENT_KEY=your_client_key
165
+ DUPR_CLIENT_SECRET=your_client_secret
166
+ ```
167
+
168
+ ---
169
+
170
+ ## 4. Authentication
171
+
172
+ ### 4.1 Client Credentials (server-to-server)
173
+
174
+ The standard mode for backend services. The SDK acquires and silently refreshes tokens.
175
+
176
+ **Internally:** POSTs to `POST /auth/{version}/token` with an `x-authorization: base64(clientKey:clientSecret)` header. The response token is cached until 60 seconds before expiry, then auto-refreshed. No action required from your code.
177
+
178
+ ```ts
179
+ const client = new DuprClient({
180
+ auth: {
181
+ type: "clientCredentials",
182
+ clientKey: "your_client_key",
183
+ clientSecret: "your_client_secret",
184
+ },
185
+ });
186
+
187
+ // Optionally pre-warm the token cache on startup:
188
+ const { token, expiresIn } = await client.auth.getToken();
189
+ ```
190
+
191
+ ---
192
+
193
+ ### 4.2 Static Bearer Token
194
+
195
+ Use when your backend handles authentication and injects a pre-obtained JWT. No token refresh is attempted.
196
+
197
+ ```ts
198
+ // Fixed token
199
+ const client = new DuprClient({
200
+ auth: { type: "staticBearer", bearerToken: "eyJ..." },
201
+ });
202
+
203
+ // Runtime injection — swap the token without rebuilding the client
204
+ client.setBearerToken("new-eyJ...");
205
+ client.clearBearerToken(); // revert to configured auth mode
206
+ ```
207
+
208
+ ---
209
+
210
+ ### 4.3 Authorization Code Flow ("Connect DUPR" button)
211
+
212
+ Use this flow to let your **end-users** authorise your app to act on their behalf — for example, showing a player's personal DUPR rating inside your app.
213
+
214
+ > **Note:** This requires a DUPR OAuth application with a user-facing authorization endpoint, separate from the Partner API client credentials. Contact DUPR support for the authorization URL and OAuth application credentials.
215
+
216
+ #### Flow overview
217
+
218
+ ```
219
+ User clicks "Connect DUPR"
220
+ │
221
+ ▼
222
+ 1. Build authorization URL → client.auth.getAuthorizationUrl({ redirectUri, state })
223
+ │
224
+ ▼
225
+ 2. Redirect user to that URL (DUPR login page)
226
+ │
227
+ ▼
228
+ 3. User approves → DUPR redirects to your redirectUri?code=AUTH_CODE&state=STATE
229
+ │
230
+ ▼
231
+ 4. Exchange code → client.auth.exchangeCode({ code, redirectUri })
232
+ │ returns { token, refresh_token, expiresIn }
233
+ ▼
234
+ 5. Store tokens. Call client.setBearerToken(token) for API calls.
235
+ │
236
+ ▼
237
+ 6. On expiry → client.auth.refreshToken(refresh_token)
238
+ ```
239
+
240
+ ```mermaid
241
+ sequenceDiagram
242
+ participant App as YourApp
243
+ participant User
244
+ participant DUPRAuth as DUPR Authorization Server
245
+ participant DUPRAPI as DUPR API
246
+
247
+ User->>App: Click "Connect with DUPR"
248
+ App->>User: Redirect to DUPRAuth (with client_id, redirect_uri, scopes)
249
+ User->>DUPRAuth: Login & Authorize
250
+ DUPRAuth-->>App: Redirect to callback with code
251
+ App->>DUPRAuth: POST /auth/v1.0/token (grant_type=authorization_code, code, redirect_uri, client_id, client_secret)
252
+ DUPRAuth-->>App: { access_token, refresh_token, expires_in }
253
+ App->>App: Save tokens (in DB or session)
254
+ App->>DUPRAPI: GET /player/v1.0/me (Authorization: Bearer access_token)
255
+ DUPRAPI-->>App: { user DUPR profile data ... }
256
+ ```
257
+
258
+ #### Implementation
259
+
260
+ **Step 1 — initiate the flow:**
261
+
262
+ ```ts
263
+ import crypto from "node:crypto";
264
+
265
+ // Inject authorizationUrl (confirm the exact URL with DUPR support)
266
+ (client.config as any).authorizationUrl = "https://auth.mydupr.com/oauth/authorize";
267
+
268
+ app.get("/auth/dupr/connect", (req, res) => {
269
+ const state = crypto.randomBytes(16).toString("hex");
270
+ req.session.duprOAuthState = state;
271
+
272
+ const url = client.auth.getAuthorizationUrl({
273
+ redirectUri: "https://yourapp.com/auth/dupr/callback",
274
+ scopes: ["user.read"],
275
+ state,
276
+ });
277
+
278
+ res.redirect(url);
279
+ });
280
+ ```
281
+
282
+ **Step 2 — handle the callback:**
283
+
284
+ ```ts
285
+ app.get("/auth/dupr/callback", async (req, res) => {
286
+ const { code, state } = req.query as Record<string, string>;
287
+
288
+ if (state !== req.session.duprOAuthState) {
289
+ return res.status(400).send("Invalid state");
290
+ }
291
+
292
+ const tokens = await client.auth.exchangeCode({
293
+ code,
294
+ redirectUri: "https://yourapp.com/auth/dupr/callback",
295
+ });
296
+
297
+ await db.users.update(req.user.id, {
298
+ duprAccessToken: tokens.token,
299
+ duprRefreshToken: tokens.refresh_token,
300
+ duprTokenExpiresAt: Date.now() + (tokens.expiresIn ?? 3600) * 1000,
301
+ });
302
+
303
+ res.redirect("/profile");
304
+ });
305
+ ```
306
+
307
+ **Step 3 — use the user token:**
308
+
309
+ ```ts
310
+ client.setBearerToken(user.duprAccessToken);
311
+ const profile = await client.users.getUser(user.duprId);
312
+ ```
313
+
314
+ **Step 4 — refresh on expiry:**
315
+
316
+ ```ts
317
+ async function getValidToken(user: UserRecord): Promise<string> {
318
+ if (Date.now() < user.duprTokenExpiresAt - 60_000) return user.duprAccessToken;
319
+
320
+ const refreshed = await client.auth.refreshToken(user.duprRefreshToken);
321
+ await db.users.update(user.id, {
322
+ duprAccessToken: refreshed.token!,
323
+ duprRefreshToken: refreshed.refresh_token ?? user.duprRefreshToken,
324
+ duprTokenExpiresAt: Date.now() + (refreshed.expiresIn ?? 3600) * 1000,
325
+ });
326
+ return refreshed.token!;
327
+ }
328
+ ```
329
+
330
+ ---
331
+
332
+ ## 5. Retry Behaviour
333
+
334
+ The SDK automatically retries requests that fail with transient errors.
335
+
336
+ | Condition | Retried? | Notes |
337
+ |---|---|---|
338
+ | `ServerError` (5xx) | Yes | |
339
+ | `RateLimitError` (429) | Yes | Honours `Retry-After` response header if present |
340
+ | Network failure (`TypeError`) | Yes | Connection refused, DNS failure, etc. |
341
+ | `ValidationError` (400) | **No** | Fix the request |
342
+ | `NotFoundError` (404) | **No** | Resource does not exist |
343
+ | `AuthenticationError` (401/403) | **No** | Fix credentials |
344
+
345
+ **Backoff formula:** `delay = min(baseDelayMs × 2ⁿ, maxDelayMs) × jitter(0.8–1.2)`
346
+
347
+ ```ts
348
+ // Custom retry policy
349
+ const client = new DuprClient({
350
+ auth: { ... },
351
+ retry: { maxRetries: 5, baseDelayMs: 500, maxDelayMs: 60_000 },
352
+ });
353
+
354
+ // Disable retry (e.g. for write operations where idempotency is not guaranteed)
355
+ const client = new DuprClient({
356
+ auth: { ... },
357
+ retry: false,
358
+ });
359
+ ```
360
+
361
+ ---
362
+
363
+ ## 6. Error Handling
364
+
365
+ All errors extend `DuprApiError`.
366
+
367
+ ```
368
+ DuprApiError
369
+ ├── AuthenticationError (401, 403)
370
+ ├── ValidationError (400)
371
+ ├── NotFoundError (404)
372
+ ├── RateLimitError (429)
373
+ └── ServerError (5xx)
374
+ ```
375
+
376
+ ### `DuprApiError` properties
377
+
378
+ | Property | Type | Description |
379
+ |---|---|---|
380
+ | `message` | `string` | Human-readable description from the DUPR response, or a generated fallback. |
381
+ | `statusCode` | `number` | HTTP status code. |
382
+ | `details` | `unknown` | Raw parsed response body. |
383
+ | `duprRequestId` | `string \| undefined` | Value of `x-request-id` response header — include in support tickets. |
384
+
385
+ ```ts
386
+ import {
387
+ DuprApiError,
388
+ AuthenticationError,
389
+ ValidationError,
390
+ NotFoundError,
391
+ RateLimitError,
392
+ ServerError,
393
+ } from "dupr-js-client";
394
+
395
+ try {
396
+ await client.matches.create(payload);
397
+ } catch (err) {
398
+ if (err instanceof ValidationError) {
399
+ console.error("Bad request:", err.details);
400
+ } else if (err instanceof RateLimitError) {
401
+ // SDK already retried — you've genuinely hit the cap
402
+ console.warn("Rate limited.");
403
+ } else if (err instanceof AuthenticationError) {
404
+ console.error("Auth failed:", err.message);
405
+ } else if (err instanceof NotFoundError) {
406
+ console.warn("Resource not found.");
407
+ } else if (err instanceof ServerError) {
408
+ console.error("DUPR server error. Request ID:", err.duprRequestId);
409
+ } else if (err instanceof DuprApiError) {
410
+ console.error(`HTTP ${err.statusCode}:`, err.message);
411
+ } else {
412
+ throw err;
413
+ }
414
+ }
415
+ ```
416
+
417
+ ---
418
+
419
+ ## 7. Observability Hooks
420
+
421
+ `onRequest` and `onResponse` let you integrate with any logging or metrics system without wrapping individual calls.
422
+
423
+ ```ts
424
+ import pino from "pino";
425
+
426
+ const log = pino();
427
+
428
+ const client = new DuprClient({
429
+ auth: { ... },
430
+
431
+ onRequest({ method, url }) {
432
+ log.debug({ method, url }, "→ DUPR");
433
+ },
434
+
435
+ onResponse({ status, url, durationMs }) {
436
+ log.debug({ status, url, durationMs }, "← DUPR");
437
+ metrics.histogram("dupr.latency", durationMs, { status: String(status) });
438
+ if (status >= 400) metrics.counter("dupr.errors", 1, { status: String(status) });
439
+ },
440
+ });
441
+ ```
442
+
443
+ `onResponse` fires once per attempt, including retried attempts. To record only final outcomes, track state in your own closure.
444
+
445
+ ---
446
+
447
+ ## 8. API Reference
448
+
449
+ All methods return `Promise<ApiWrapper<T>>`.
450
+
451
+ ```ts
452
+ interface ApiWrapper<T = unknown> {
453
+ status: "SUCCESS" | "FAILURE";
454
+ message?: string;
455
+ result?: T; // the actual payload
456
+ }
457
+ ```
458
+
459
+ ---
460
+
461
+ ### 8.1 `client.auth`
462
+
463
+ | Method | Description |
464
+ |---|---|
465
+ | `getToken()` | Explicitly fetch a client-credentials token. Normally auto-managed. |
466
+ | `getAuthorizationUrl(params)` | Build the OAuth redirect URL for user-facing login. |
467
+ | `exchangeCode({ code, redirectUri })` | Exchange an auth code for `{ token, refresh_token, expiresIn }`. |
468
+ | `refreshToken(refreshToken)` | Get a new access token using a refresh token. |
469
+
470
+ See [§4](#4-authentication) for full examples.
471
+
472
+ ---
473
+
474
+ ### 8.2 `client.users`
475
+
476
+ | Method | Endpoint | Description |
477
+ |---|---|---|
478
+ | `getUser(duprId)` | `GET /user/{v}/{id}` | Basic player profile |
479
+ | `getExtendedUser(duprId)` | `GET /user/{v}/{id}/details` | Profile + email (requires `USER_EMAIL::VIEW` permission) |
480
+ | `getClubMemberships(duprId)` | `GET /user/{v}/{id}/clubs` | Club memberships |
481
+ | `search(req)` | `POST /user/{v}/search` | Full-text search with optional filters |
482
+ | `getBatch(req)` | `POST /user/{v}/batch` | Fetch multiple players by DUPR ID in one request |
483
+ | `invite(req)` | `POST /user/{v}/invite` | Pre-generate a DUPR ID and send an invite email |
484
+ | `grantSubscription(req)` | `POST /user/{v}/subscription/grants` | Grant a product subscription to a user |
485
+ | `getProvisionalRating(req)` | `POST /user/{v}/provisional_rating` | Get provisional ratings |
486
+ | `createProvisionalRating(req)` | `POST /user/{v}/provisional_rating/create` | Set provisional ratings |
487
+ | `updateProvisionalRating(req)` | `POST /user/{v}/provisional_rating/update` | Update provisional ratings |
488
+ | `deleteProvisionalRating(req)` | `DELETE /user/{v}/provisional_rating/delete` | Delete provisional ratings |
489
+
490
+ **`search` example with filters:**
491
+
492
+ ```ts
493
+ const results = await client.users.search({
494
+ query: "Maria Garcia",
495
+ offset: 0,
496
+ limit: 20,
497
+ filters: {
498
+ gender: "FEMALE",
499
+ rating: { type: "DOUBLES", min: 4.0, max: 5.5, reliable: true },
500
+ location: { lat: 37.77, lng: -122.41, radiusInMeters: 50_000 },
501
+ age: { min: 25, max: 45 },
502
+ },
503
+ });
504
+ ```
505
+
506
+ ---
507
+
508
+ ### 8.3 `client.players`
509
+
510
+ | Method | Endpoint | Description |
511
+ |---|---|---|
512
+ | `getDuprIdByEmail({ email })` | `POST /{v}/player/duprid-by-email` | Resolve a DUPR ID from an email address |
513
+
514
+ ```ts
515
+ const { result } = await client.players.getDuprIdByEmail({ email: "player@example.com" });
516
+ // result: { duprId: "XYZ789" }
517
+ ```
518
+
519
+ ---
520
+
521
+ ### 8.4 `client.playerRating`
522
+
523
+ | Method | Endpoint | Description |
524
+ |---|---|---|
525
+ | `getHistory(req)` | `POST /history` | Rating history for a player |
526
+ | `getSubscriptions()` | `GET /{v}/subscribe/rating-changes` | List currently subscribed DUPR IDs |
527
+ | `subscribe(req)` | `POST /{v}/subscribe/rating-changes` | Subscribe to rating-change events |
528
+ | `unsubscribe(req)` | `DELETE /{v}/subscribe/rating-changes` | Unsubscribe from rating-change events |
529
+
530
+ ```ts
531
+ // Fetch rating history
532
+ const { result } = await client.playerRating.getHistory({
533
+ duprId: "ABC123",
534
+ offset: 0,
535
+ limit: 50,
536
+ });
537
+ // result: [{ date, singlesRating, doublesRating }, ...]
538
+
539
+ // Subscribe to webhook notifications for a list of players
540
+ await client.playerRating.subscribe({ duprIds: ["AAA111", "BBB222"] });
541
+ ```
542
+
543
+ ---
544
+
545
+ ### 8.5 `client.matches`
546
+
547
+ > **Important:** The `identifier` field must be universally unique across your entire application. It must never be reused, even if a match is deleted.
548
+
549
+ | Method | Endpoint | Description |
550
+ |---|---|---|
551
+ | `get(matchId)` | `GET /match/{v}/{id}` | View a match by DUPR match code |
552
+ | `create(match)` | `POST /match/{v}/create` | Submit a new match result |
553
+ | `createBulk(matches[])` | `POST /match/{v}/batch` | Submit multiple matches at once |
554
+ | `update(req)` | `POST /match/{v}/update` | Update an existing match |
555
+ | `delete(req)` | `DELETE /match/{v}/delete` | Delete a match |
556
+ | `annotate(req)` | `POST /match/{v}/annotate` | Attach vendor metadata to a match |
557
+ | `deleteAnnotation(matchId)` | `DELETE /match/{v}/annotate/{id}` | Remove a match annotation |
558
+ | `searchHistory(req)` | `POST /match/history/search` | Search a player's match history |
559
+
560
+ **`create` full example:**
561
+
562
+ ```ts
563
+ const { result } = await client.matches.create({
564
+ identifier: "session-42-match-7", // your unique ID
565
+ matchDate: "2024-06-15",
566
+ matchFormat: "DOUBLES",
567
+ source: "PARTNER",
568
+ teams: [
569
+ {
570
+ players: [{ duprId: "AAA111" }, { duprId: "BBB222" }],
571
+ scores: [11, 7, 11], // scores per game (won games 1 and 3)
572
+ },
573
+ {
574
+ players: [{ duprId: "CCC333" }, { duprId: "DDD444" }],
575
+ scores: [8, 11, 9],
576
+ },
577
+ ],
578
+ clubId: 123, // optional
579
+ eventId: 456, // optional
580
+ });
581
+ console.log(result?.matchCode); // DUPR's canonical match code
582
+ ```
583
+
584
+ **`ExternalMatchTeam` fields:**
585
+
586
+ | Field | Type | Notes |
587
+ |---|---|---|
588
+ | `players` | `ExternalMatchPlayer[]` | 1 player for SINGLES, 2 for DOUBLES |
589
+ | `scores` | `number[]` | Per-game scores. Both teams' arrays must be the same length. |
590
+
591
+ ---
592
+
593
+ ### 8.6 `client.clubs`
594
+
595
+ | Method | Endpoint | Description |
596
+ |---|---|---|
597
+ | `membersRating({ clubId })` | `POST /club/{v}/members` | DUPR ratings for all club members |
598
+ | `searchMatches({ clubId, offset?, limit? })` | `POST /club/{v}/match/search` | Matches associated with a club |
599
+
600
+ ---
601
+
602
+ ### 8.7 `client.events`
603
+
604
+ | Method | Endpoint | Description |
605
+ |---|---|---|
606
+ | `create(req)` | `POST /events/{v}/create` | Create an event |
607
+ | `get({ eventIds })` | `POST /events/{v}/get` | Get one or more events by ID |
608
+ | `update(req)` | `POST /events/{v}/update` | Update an event |
609
+ | `delete({ eventIds })` | `POST /events/{v}/delete` | Delete events |
610
+
611
+ ```ts
612
+ const { result } = await client.events.create({
613
+ name: "Summer Slam 2024",
614
+ description: "Annual club championship",
615
+ startDate: "2024-07-01",
616
+ endDate: "2024-07-03",
617
+ location: "San Francisco, CA",
618
+ clubId: 123,
619
+ });
620
+ // result: { eventId, name }
621
+ ```
622
+
623
+ ---
624
+
625
+ ### 8.8 `client.webhooks`
626
+
627
+ | Method | Endpoint | Description |
628
+ |---|---|---|
629
+ | `register({ webhookUrl, topics })` | `POST /{v}/webhook` | Register your HTTPS webhook endpoint |
630
+ | `getTopics()` | `GET /{v}/topic` | List available webhook topics |
631
+ | `listSchemas()` | `GET /{v}/webhook/schema` | List available webhook schemas |
632
+ | `getSchema(topic)` | `GET /{v}/webhook/schema/{topic}` | Get JSON schema for a topic |
633
+ | `subscribeUsers({ duprIds, topic })` | `POST /user/{v}/subscribe/webhook-event` | Subscribe players to webhook notifications |
634
+ | `unsubscribeUsers({ duprIds, topic })` | `DELETE /user/{v}/subscribe/webhook-event` | Unsubscribe players |
635
+
636
+ ```ts
637
+ // Register your endpoint to receive RATING change events
638
+ await client.webhooks.register({
639
+ webhookUrl: "https://yourapp.com/webhooks/dupr", // must be HTTPS
640
+ topics: ["RATING"],
641
+ });
642
+
643
+ // Subscribe specific players
644
+ await client.webhooks.subscribeUsers({
645
+ duprIds: ["ABC123", "DEF456"],
646
+ topic: "RATING",
647
+ });
648
+ ```
649
+
650
+ ---
651
+
652
+ ## 9. Type Reference
653
+
654
+ All types are exported from the package root.
655
+
656
+ ### Enums / union types
657
+
658
+ | Type | Values |
659
+ |---|---|
660
+ | `MatchFormat` | `"SINGLES" \| "DOUBLES"` |
661
+ | `MatchSource` | `"PARTNER" \| "CLUB"` |
662
+ | `Gender` | `"MALE" \| "FEMALE"` |
663
+ | `RatingType` | `"SINGLES" \| "DOUBLES"` |
664
+ | `WebhookTopic` | `"RATING"` |
665
+ | `ApiStatus` | `"SUCCESS" \| "FAILURE"` |
666
+
667
+ ### Core response shapes
668
+
669
+ ```ts
670
+ interface ApiWrapper<T = unknown> { status: ApiStatus; message?: string; result?: T; }
671
+
672
+ interface UserInfo {
673
+ duprId: string; fullName: string;
674
+ singlesRating?: number; doublesRating?: number;
675
+ singlesProvisional?: boolean; doublesProvisional?: boolean;
676
+ }
677
+
678
+ interface ExtendedUserInfo extends UserInfo { email?: string; }
679
+
680
+ interface MatchResponse {
681
+ matchCode?: string; hashedMatchCode?: string;
682
+ identifier?: string; matchDate?: string;
683
+ matchFormat?: MatchFormat; teams?: ExternalMatchTeam[];
684
+ }
685
+
686
+ interface TokenResponse { token?: string; accessToken?: string; expiresIn?: number; }
687
+
688
+ interface AuthCodeTokenResponse extends TokenResponse {
689
+ refresh_token?: string; token_type?: string; scope?: string;
690
+ }
691
+ ```
692
+
693
+ ### Request type → method mapping
694
+
695
+ | Type | Used by |
696
+ |---|---|
697
+ | `ExternalMatchRequest` | `matches.create()` |
698
+ | `ExternalUpdateMatchRequest` | `matches.update()` |
699
+ | `ExternalDeleteMatchRequest` | `matches.delete()` |
700
+ | `ExternalMatchSearchRequest` | `matches.searchHistory()` |
701
+ | `ExternalSearchRequest` | `users.search()` |
702
+ | `ExternalSearchFilter` | `users.search()` — `filters` field |
703
+ | `ExternalFilterLocation` | `ExternalSearchFilter.location` |
704
+ | `ExternalRatingFilter` | `ExternalSearchFilter.rating` |
705
+ | `ExternalAgeRangeFilter` | `ExternalSearchFilter.age` |
706
+ | `ExternalBatchUserDetailRequest` | `users.getBatch()` |
707
+ | `ExternalInviteRequest` | `users.invite()` |
708
+ | `CreateProvisionalRatingRequest` | `users.createProvisionalRating()` |
709
+ | `UpdateProvisionalRatingRequest` | `users.updateProvisionalRating()` |
710
+ | `PlayerRatingSubscribeRequest` | `playerRating.subscribe/unsubscribe()` |
711
+ | `GrantExternalSubscriptionRequest` | `users.grantSubscription()` |
712
+ | `ExternalClubMemberRequest` | `clubs.membersRating()` |
713
+ | `ExternalClubMatchSearchRequest` | `clubs.searchMatches()` |
714
+ | `CreateEventRequestV1` | `events.create()` |
715
+ | `UpdateEventRequestV1` | `events.update()` |
716
+ | `GetEventRequestV1` | `events.get()` |
717
+ | `DeleteEventRequestV1` | `events.delete()` |
718
+ | `ClientHookRequest` | `webhooks.register()` |
719
+ | `UserWebhookRequest` | `webhooks.subscribeUsers/unsubscribeUsers()` |
720
+
721
+ ---
722
+
723
+ ## 10. Testing
724
+
725
+ Inject a mock `fetch` via `customFetch` — no real network calls, no test server needed.
726
+
727
+ ```ts
728
+ import { describe, it, expect, vi } from "vitest";
729
+ import { DuprClient, NotFoundError } from "dupr-js-client";
730
+
731
+ function makeClient(fetchMock: ReturnType<typeof vi.fn>) {
732
+ return new DuprClient({
733
+ auth: { type: "staticBearer", bearerToken: "test-token" },
734
+ customFetch: fetchMock as typeof fetch,
735
+ retry: false, // deterministic — no backoff sleeps in tests
736
+ });
737
+ }
738
+
739
+ it("returns the player on 200", async () => {
740
+ const fetch = vi.fn().mockResolvedValue(
741
+ new Response(
742
+ JSON.stringify({ status: "SUCCESS", result: { duprId: "ABC123", fullName: "Jane Smith" } }),
743
+ { status: 200, headers: { "Content-Type": "application/json" } },
744
+ ),
745
+ );
746
+ const { result } = await makeClient(fetch).users.getUser("ABC123");
747
+ expect(result?.fullName).toBe("Jane Smith");
748
+ });
749
+
750
+ it("throws NotFoundError on 404", async () => {
751
+ const fetch = vi.fn().mockResolvedValue(
752
+ new Response(JSON.stringify({ message: "Not found" }), { status: 404 }),
753
+ );
754
+ await expect(makeClient(fetch).users.getUser("NOPE")).rejects.toBeInstanceOf(NotFoundError);
755
+ });
756
+ ```
757
+
758
+ **Testing retry with fake timers:**
759
+
760
+ ```ts
761
+ import { vi } from "vitest";
762
+
763
+ beforeEach(() => { vi.useFakeTimers(); });
764
+ afterEach(() => { vi.useRealTimers(); });
765
+
766
+ it("retries once then succeeds", async () => {
767
+ const fetch = vi.fn()
768
+ .mockResolvedValueOnce(new Response("{}", { status: 503 }))
769
+ .mockResolvedValueOnce(
770
+ new Response(JSON.stringify({ status: "SUCCESS" }), { status: 200 }),
771
+ );
772
+
773
+ const client = new DuprClient({
774
+ auth: { type: "staticBearer", bearerToken: "tok" },
775
+ customFetch: fetch as typeof fetch,
776
+ retry: { maxRetries: 1, baseDelayMs: 100 },
777
+ });
778
+
779
+ const promise = client.users.getUser("ABC");
780
+ // Attach assertion BEFORE advancing timers to prevent unhandled-rejection warnings
781
+ const assertion = expect(promise).resolves.toMatchObject({ status: "SUCCESS" });
782
+ await vi.runAllTimersAsync();
783
+ await assertion;
784
+ expect(fetch).toHaveBeenCalledTimes(2);
785
+ });
786
+ ```
787
+
788
+ ---
789
+
790
+ ## 11. Express Integration Example
791
+
792
+ ```ts
793
+ // server/src/duprClient.ts
794
+ import { DuprClient } from "dupr-js-client";
795
+
796
+ export const dupr = new DuprClient({
797
+ baseUrl: process.env.DUPR_BASE_URL ?? "https://uat.mydupr.com/api",
798
+ auth: {
799
+ type: "clientCredentials",
800
+ clientKey: process.env.DUPR_CLIENT_KEY!,
801
+ clientSecret: process.env.DUPR_CLIENT_SECRET!,
802
+ },
803
+ onRequest: ({ method, url }) => console.log(`[DUPR] → ${method} ${url}`),
804
+ onResponse: ({ status, durationMs }) => console.log(`[DUPR] ← ${status} in ${durationMs}ms`),
805
+ });
806
+ ```
807
+
808
+ ```ts
809
+ // server/src/routes/dupr.ts
810
+ import { Router } from "express";
811
+ import { dupr } from "../duprClient.js";
812
+ import { NotFoundError, ValidationError } from "dupr-js-client";
813
+
814
+ const router = Router();
815
+
816
+ // GET /api/dupr/players/:duprId
817
+ router.get("/players/:duprId", async (req, res, next) => {
818
+ try {
819
+ res.json(await dupr.users.getUser(req.params.duprId));
820
+ } catch (err) {
821
+ if (err instanceof NotFoundError) return res.status(404).json({ error: "Player not found" });
822
+ next(err);
823
+ }
824
+ });
825
+
826
+ // POST /api/dupr/matches
827
+ router.post("/matches", async (req, res, next) => {
828
+ try {
829
+ res.status(201).json(await dupr.matches.create(req.body));
830
+ } catch (err) {
831
+ if (err instanceof ValidationError) return res.status(400).json({ error: err.message, details: err.details });
832
+ next(err);
833
+ }
834
+ });
835
+
836
+ // GET /api/dupr/players/:duprId/history
837
+ router.get("/players/:duprId/history", async (req, res, next) => {
838
+ try {
839
+ res.json(await dupr.playerRating.getHistory({
840
+ duprId: req.params.duprId,
841
+ offset: Number(req.query.offset ?? 0),
842
+ limit: Number(req.query.limit ?? 20),
843
+ }));
844
+ } catch (err) {
845
+ next(err);
846
+ }
847
+ });
848
+
849
+ export default router;
850
+ ```
851
+
852
+ ---
853
+
854
+ ## 12. Regenerating Types from the OpenAPI Spec
855
+
856
+ The types in `src/types.ts` are hand-rolled from the DUPR Partner APIs OpenAPI 3.1.0 spec. To regenerate from the live spec:
857
+
858
+ ```bash
859
+ npm run types:generate
860
+ ```
861
+
862
+ This runs:
863
+
864
+ ```bash
865
+ npx openapi-typescript "https://uat.mydupr.com/api/v3/api-docs/DUPR%20Partner%20APIs" \
866
+ -o src/types.generated.ts
867
+ ```
868
+
869
+ Then update `src/types.ts` to import from the generated file:
870
+
871
+ ```ts
872
+ import type { components } from "./types.generated.js";
873
+
874
+ export type ExternalMatchRequest = components["schemas"]["ExternalMatchRequest"];
875
+ export type UserInfo = components["schemas"]["UserDetail"];
876
+ // etc.
877
+ ```
878
+
879
+ Review the diff carefully before committing — field names in the live spec may differ from what this version uses. Run `npm test` after updating.
880
+
881
+ ---
882
+
883
+ ## 13. Development
884
+
885
+ ```bash
886
+ git clone https://github.com/sunnytambi/dupr-js-client.git
887
+ cd dupr-js-client
888
+ npm install
889
+
890
+ npm test # vitest — 57 tests
891
+ npm run build # tsup → dist/ (ESM + CJS + .d.ts)
892
+ npm run typecheck # tsc --noEmit
893
+ npm run lint # eslint
894
+ npm run format # prettier
895
+ ```
896
+
897
+ CI runs on Node.js 18, 20, and 22 on every push and pull request.
898
+
899
+ ---
900
+
901
+ ## 14. Contributing
902
+
903
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for branch conventions, commit message format, test guidelines, and the release process.
904
+
905
+ ---
906
+
907
+ ## 15. License
908
+
909
+ MIT © [Sunny Tambi](https://github.com/sunnytambi)