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/LICENSE +21 -0
- package/README.md +909 -0
- package/dist/index.cjs +603 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +516 -0
- package/dist/index.d.ts +516 -0
- package/dist/index.js +587 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
# dupr-js-client
|
|
2
|
+
|
|
3
|
+
[](https://github.com/sunnytambi/dupr-js-client/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/dupr-js-client)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+

|
|
7
|
+

|
|
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)
|