@djangocfg/api 2.1.332 → 2.1.334

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/api",
3
- "version": "2.1.332",
3
+ "version": "2.1.334",
4
4
  "description": "Auto-generated TypeScript API client with React hooks, SWR integration, and Zod validation for Django REST Framework backends",
5
5
  "keywords": [
6
6
  "django",
@@ -79,7 +79,7 @@
79
79
  "devDependencies": {
80
80
  "@types/node": "^24.7.2",
81
81
  "@types/react": "^19.1.0",
82
- "@djangocfg/typescript-config": "^2.1.332",
82
+ "@djangocfg/typescript-config": "^2.1.334",
83
83
  "next": "^16.2.2",
84
84
  "react": "^19.1.0",
85
85
  "tsup": "^8.5.0",
@@ -61,6 +61,18 @@ export class API {
61
61
  setLocale(locale: string | null): void { auth.setLocale(locale); }
62
62
  getApiKey(): string | null { return auth.getApiKey(); }
63
63
  setApiKey(key: string | null): void { auth.setApiKey(key); }
64
+
65
+ // ── 401 handling ────────────────────────────────────────────────────────
66
+ /** Fired only on terminal 401 (after refresh+retry path is exhausted). */
67
+ onUnauthorized(cb: ((response: Response) => void) | null): void {
68
+ auth.onUnauthorized(cb);
69
+ }
70
+ /** Provide a refresh strategy. See `auth.setRefreshHandler` for the contract. */
71
+ setRefreshHandler(
72
+ fn: ((refreshToken: string) => Promise<{ access: string; refresh?: string } | null>) | null,
73
+ ): void {
74
+ auth.setRefreshHandler(fn);
75
+ }
64
76
  }
65
77
 
66
78
  export { };
@@ -61,6 +61,18 @@ export class API {
61
61
  setLocale(locale: string | null): void { auth.setLocale(locale); }
62
62
  getApiKey(): string | null { return auth.getApiKey(); }
63
63
  setApiKey(key: string | null): void { auth.setApiKey(key); }
64
+
65
+ // ── 401 handling ────────────────────────────────────────────────────────
66
+ /** Fired only on terminal 401 (after refresh+retry path is exhausted). */
67
+ onUnauthorized(cb: ((response: Response) => void) | null): void {
68
+ auth.onUnauthorized(cb);
69
+ }
70
+ /** Provide a refresh strategy. See `auth.setRefreshHandler` for the contract. */
71
+ setRefreshHandler(
72
+ fn: ((refreshToken: string) => Promise<{ access: string; refresh?: string } | null>) | null,
73
+ ): void {
74
+ auth.setRefreshHandler(fn);
75
+ }
64
76
  }
65
77
 
66
78
  export { };
@@ -61,6 +61,18 @@ export class API {
61
61
  setLocale(locale: string | null): void { auth.setLocale(locale); }
62
62
  getApiKey(): string | null { return auth.getApiKey(); }
63
63
  setApiKey(key: string | null): void { auth.setApiKey(key); }
64
+
65
+ // ── 401 handling ────────────────────────────────────────────────────────
66
+ /** Fired only on terminal 401 (after refresh+retry path is exhausted). */
67
+ onUnauthorized(cb: ((response: Response) => void) | null): void {
68
+ auth.onUnauthorized(cb);
69
+ }
70
+ /** Provide a refresh strategy. See `auth.setRefreshHandler` for the contract. */
71
+ setRefreshHandler(
72
+ fn: ((refreshToken: string) => Promise<{ access: string; refresh?: string } | null>) | null,
73
+ ): void {
74
+ auth.setRefreshHandler(fn);
75
+ }
64
76
  }
65
77
 
66
78
  export { };
@@ -15,5 +15,6 @@ export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (over
15
15
 
16
16
  export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://localhost:8000' }));
17
17
 
18
- // auto-init: load auth interceptor
19
- import './helpers/auth';
18
+ // auto-init: install auth on client
19
+ import { installAuthOnClient } from './helpers/auth';
20
+ installAuthOnClient(client);
@@ -1,10 +1,9 @@
1
1
  // AUTO-GENERATED by django_generator / ts_extras.wrapper
2
- // Global auth store. ONE source of truth for token / API key / locale /
3
- // baseUrl. Installs the request interceptor as a side-effect on import.
2
+ // Global auth store. Wired into the shared `client` from `client.gen.ts`
3
+ // via `installAuthOnClient(client)` called synchronously by the
4
+ // post-processed bottom of client.gen.ts. No circular import here.
4
5
  // DO NOT EDIT — re-run `make gen`.
5
6
 
6
- import { client } from '../client.gen';
7
-
8
7
  const ACCESS_KEY = 'cfg.access_token';
9
8
  const REFRESH_KEY = 'cfg.refresh_token';
10
9
  const API_KEY_KEY = 'cfg.api_key';
@@ -106,6 +105,45 @@ let _baseUrlOverride: string | null = null;
106
105
  let _withCredentials = true;
107
106
  let _onUnauthorized: ((response: Response) => void) | null = null;
108
107
 
108
+ /**
109
+ * User-supplied refresh handler. Receives the current refresh token,
110
+ * must return a fresh access (and optional refresh) pair or null on failure.
111
+ * Set once at app bootstrap via `auth.setRefreshHandler(...)`.
112
+ */
113
+ type RefreshResult = { access: string; refresh?: string } | null;
114
+ type RefreshHandler = (refreshToken: string) => Promise<RefreshResult>;
115
+ let _refreshHandler: RefreshHandler | null = null;
116
+
117
+ /** Single-flight: every concurrent 401 awaits the same refresh. */
118
+ let _refreshInflight: Promise<string | null> | null = null;
119
+
120
+ /** Marker header — set on retried requests so we never loop on 401. */
121
+ const RETRY_MARKER = 'X-Auth-Retry';
122
+
123
+ /**
124
+ * Captured reference to the shared Hey API client. Set exactly once by
125
+ * `installAuthOnClient(client)` (called from client.gen.ts). All `auth.set*`
126
+ * methods that mutate transport config (baseUrl / credentials) push through
127
+ * this reference. Until installed, those mutations are silently buffered as
128
+ * in-memory state — the next request after install will pick them up.
129
+ */
130
+ type HeyClient = {
131
+ setConfig(opts: Record<string, unknown>): void;
132
+ interceptors: {
133
+ request: { use(fn: (req: Request) => Request | Promise<Request>): void };
134
+ response: { use(fn: (res: Response, req: Request) => Response | Promise<Response>): void };
135
+ };
136
+ };
137
+ let _client: HeyClient | null = null;
138
+
139
+ function pushClientConfig(): void {
140
+ if (!_client) return;
141
+ _client.setConfig({
142
+ baseUrl: auth.getBaseUrl(),
143
+ credentials: _withCredentials ? 'include' : 'same-origin',
144
+ });
145
+ }
146
+
109
147
  /**
110
148
  * Global auth/config store. All getters read live state every call —
111
149
  * the interceptor below uses these to attach headers per-request.
@@ -116,24 +154,13 @@ let _onUnauthorized: ((response: Response) => void) | null = null;
116
154
  *
117
155
  * @example
118
156
  * import { auth } from '@your/api';
119
- *
120
- * // After login
121
157
  * auth.setToken(jwt);
122
- * auth.setRefreshToken(refresh);
123
- *
124
- * // After logout
125
158
  * auth.clearTokens();
126
- *
127
- * // Switch to cookie storage (call once during app init)
128
159
  * auth.setStorageMode('cookie');
129
160
  */
130
161
  export const auth = {
131
162
  // ── Storage mode ──────────────────────────────────────────────────
132
163
  getStorageMode(): StorageMode { return _storageMode; },
133
- /**
134
- * Switch the storage backend. Existing values in the *previous*
135
- * backend are NOT migrated — set fresh values after switching.
136
- */
137
164
  setStorageMode(mode: StorageMode): void {
138
165
  _storageMode = mode;
139
166
  _storage = mode === 'cookie' ? cookieBackend : localStorageBackend;
@@ -148,13 +175,10 @@ export const auth = {
148
175
  isAuthenticated(): boolean { return _storage.get(ACCESS_KEY) !== null; },
149
176
 
150
177
  // ── API key ───────────────────────────────────────────────────────
151
- /** In-memory API key. Falls back to storage, then NEXT_PUBLIC_API_KEY. */
152
178
  getApiKey(): string | null {
153
179
  return _apiKeyOverride ?? _storage.get(API_KEY_KEY) ?? defaultApiKey();
154
180
  },
155
- /** In-memory only (cleared on reload). */
156
181
  setApiKey(key: string | null): void { _apiKeyOverride = key; },
157
- /** Persist to active storage backend (localStorage or cookie). */
158
182
  setApiKeyPersist(key: string | null): void {
159
183
  _apiKeyOverride = key;
160
184
  _storage.set(API_KEY_KEY, key);
@@ -162,7 +186,6 @@ export const auth = {
162
186
  clearApiKey(): void { _apiKeyOverride = null; _storage.set(API_KEY_KEY, null); },
163
187
 
164
188
  // ── Locale ────────────────────────────────────────────────────────
165
- /** Override locale → falls back to NEXT_LOCALE cookie / navigator.language. */
166
189
  getLocale(): string | null { return _localeOverride ?? detectLocale(); },
167
190
  setLocale(locale: string | null): void { _localeOverride = locale; },
168
191
 
@@ -173,51 +196,144 @@ export const auth = {
173
196
  },
174
197
  setBaseUrl(url: string | null): void {
175
198
  _baseUrlOverride = url ? url.replace(/\/$/, '') : null;
176
- client.setConfig({ baseUrl: this.getBaseUrl() });
199
+ pushClientConfig();
177
200
  },
178
201
 
179
- // ── Credentials toggle (Django session/CSRF cross-origin) ─────────
202
+ // ── Credentials toggle ────────────────────────────────────────────
180
203
  getWithCredentials(): boolean { return _withCredentials; },
181
204
  setWithCredentials(value: boolean): void {
182
205
  _withCredentials = value;
183
- client.setConfig({ credentials: value ? 'include' : 'same-origin' });
206
+ pushClientConfig();
184
207
  },
185
208
 
186
209
  // ── 401 handler ───────────────────────────────────────────────────
187
210
  /**
188
- * Register a callback fired on every 401 response. Use this to wire
189
- * a token-refresh flow or a forced logout. Setting `null` removes
190
- * the handler.
211
+ * Fired when the server returns 401 AND no refresh path recovers it
212
+ * (no refresh token, no refresh handler, refresh failed, or retry
213
+ * still 401). The app should clear local state and redirect to login.
214
+ *
215
+ * NOT fired for 401 that gets transparently recovered by the refresh
216
+ * handler — those are invisible to callers.
191
217
  */
192
218
  onUnauthorized(cb: ((response: Response) => void) | null): void {
193
219
  _onUnauthorized = cb;
194
220
  },
221
+
222
+ /**
223
+ * Register the refresh strategy. The handler receives the current
224
+ * refresh token and must call your refresh endpoint, returning
225
+ * `{ access, refresh? }` on success or `null` on failure.
226
+ *
227
+ * @example
228
+ * auth.setRefreshHandler(async (refresh) => {
229
+ * const { data } = await Auth.tokenRefreshCreate({ body: { refresh } });
230
+ * return data ? { access: data.access, refresh: data.refresh } : null;
231
+ * });
232
+ */
233
+ setRefreshHandler(fn: RefreshHandler | null): void {
234
+ _refreshHandler = fn;
235
+ },
195
236
  };
196
237
 
197
- // ── One-time client wiring (side-effect on first import) ───────────────────
198
- client.setConfig({
199
- baseUrl: auth.getBaseUrl(),
200
- credentials: _withCredentials ? 'include' : 'same-origin',
201
- });
238
+ /**
239
+ * Run the user-supplied refresh handler under single-flight, persist
240
+ * the new tokens, and return the fresh access token (or null on any
241
+ * failure path). All concurrent 401s share the same in-flight promise.
242
+ */
243
+ async function tryRefresh(): Promise<string | null> {
244
+ if (_refreshInflight) return _refreshInflight;
245
+ if (!_refreshHandler) return null;
246
+ const refresh = auth.getRefreshToken();
247
+ if (!refresh) return null;
202
248
 
203
- client.interceptors.request.use((request) => {
204
- const token = auth.getToken();
205
- if (token) request.headers.set('Authorization', `Bearer ${token}`);
249
+ _refreshInflight = (async () => {
250
+ try {
251
+ const result = await _refreshHandler!(refresh);
252
+ if (!result?.access) return null;
253
+ auth.setToken(result.access);
254
+ if (result.refresh) auth.setRefreshToken(result.refresh);
255
+ return result.access;
256
+ } catch {
257
+ return null;
258
+ } finally {
259
+ _refreshInflight = null;
260
+ }
261
+ })();
262
+
263
+ return _refreshInflight;
264
+ }
265
+
266
+ /**
267
+ * Wire the shared client to the global auth store. Called exactly
268
+ * once from `client.gen.ts` (post-processed) right after
269
+ * `createClient()`. Synchronous — no microtask, no TDZ races.
270
+ *
271
+ * Safe to call from server / SSR: storage backends short-circuit on
272
+ * non-browser environments, so headers populated by the interceptor
273
+ * are simply absent server-side (which is the correct behaviour
274
+ * unless the caller explicitly sets a server-side token).
275
+ */
276
+ export function installAuthOnClient(client: HeyClient): void {
277
+ if (_client) return; // idempotent
278
+ _client = client;
279
+
280
+ client.setConfig({
281
+ baseUrl: auth.getBaseUrl(),
282
+ credentials: _withCredentials ? 'include' : 'same-origin',
283
+ });
284
+
285
+ client.interceptors.request.use((request) => {
286
+ const token = auth.getToken();
287
+ if (token) request.headers.set('Authorization', `Bearer ${token}`);
288
+
289
+ const locale = auth.getLocale();
290
+ if (locale) request.headers.set('Accept-Language', locale);
206
291
 
207
- const locale = auth.getLocale();
208
- if (locale) request.headers.set('Accept-Language', locale);
292
+ const apiKey = auth.getApiKey();
293
+ if (apiKey) request.headers.set('X-API-Key', apiKey);
209
294
 
210
- const apiKey = auth.getApiKey();
211
- if (apiKey) request.headers.set('X-API-Key', apiKey);
295
+ return request;
296
+ });
212
297
 
213
- return request;
214
- });
298
+ client.interceptors.response.use(async (response, request) => {
299
+ if (response.status !== 401) return response;
215
300
 
216
- client.interceptors.response.use((response) => {
217
- if (response.status === 401 && _onUnauthorized) {
218
- try { _onUnauthorized(response); } catch {}
219
- }
220
- return response;
221
- });
301
+ // Already retried once — give up to avoid loops.
302
+ if (request.headers.get(RETRY_MARKER)) {
303
+ if (_onUnauthorized) {
304
+ try { _onUnauthorized(response); } catch {}
305
+ }
306
+ return response;
307
+ }
308
+
309
+ const newToken = await tryRefresh();
310
+ if (!newToken) {
311
+ if (_onUnauthorized) {
312
+ try { _onUnauthorized(response); } catch {}
313
+ }
314
+ return response;
315
+ }
316
+
317
+ // Retry the original request once with the new token. We mutate a
318
+ // clone so the original Request body (already consumed by the
319
+ // failed call) doesn't trip "body already used".
320
+ const retry = request.clone();
321
+ retry.headers.set('Authorization', `Bearer ${newToken}`);
322
+ retry.headers.set(RETRY_MARKER, '1');
323
+ try {
324
+ const retried = await fetch(retry);
325
+ if (retried.status === 401 && _onUnauthorized) {
326
+ try { _onUnauthorized(retried); } catch {}
327
+ }
328
+ return retried;
329
+ } catch {
330
+ // Network error on retry — surface the original 401.
331
+ if (_onUnauthorized) {
332
+ try { _onUnauthorized(response); } catch {}
333
+ }
334
+ return response;
335
+ }
336
+ });
337
+ }
222
338
 
223
339
  export type Auth = typeof auth;