@rdlabo/ionic-angular-kit 0.0.2 → 0.0.3
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 +20 -8
- package/ng-package.json +7 -0
- package/package.json +3 -15
- package/src/lib/auth/auth-guards.spec.ts +210 -0
- package/src/lib/auth/auth-guards.ts +227 -0
- package/src/lib/directives/autofill.directive.ts +68 -0
- package/src/lib/http/kit-http.interceptor.spec.ts +300 -0
- package/src/lib/http/kit-http.interceptor.ts +236 -0
- package/src/lib/overlay/kit-overlay.controller.spec.ts +233 -0
- package/src/lib/overlay/kit-overlay.controller.ts +206 -0
- package/src/lib/overlay/kit-reload-alert.controller.spec.ts +105 -0
- package/src/lib/overlay/kit-reload-alert.controller.ts +108 -0
- package/src/lib/overlay/overlay-config.ts +53 -0
- package/src/lib/storage/kit-storage.service.spec.ts +127 -0
- package/src/lib/storage/kit-storage.service.ts +91 -0
- package/src/lib/utils/array.spec.ts +33 -0
- package/src/lib/utils/array.ts +82 -0
- package/src/lib/utils/haptics.ts +32 -0
- package/src/public-api.ts +24 -0
- package/tsconfig.lib.json +14 -0
- package/tsconfig.lib.prod.json +11 -0
- package/tsconfig.spec.json +10 -0
- package/fesm2022/rdlabo-ionic-angular-kit.mjs +0 -750
- package/fesm2022/rdlabo-ionic-angular-kit.mjs.map +0 -1
- package/types/rdlabo-ionic-angular-kit.d.ts +0 -733
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { provideZonelessChangeDetection } from '@angular/core';
|
|
2
|
+
import { TestBed } from '@angular/core/testing';
|
|
3
|
+
import { HttpErrorResponse, HttpRequest, HttpResponse } from '@angular/common/http';
|
|
4
|
+
import { Observable } from 'rxjs';
|
|
5
|
+
import { of, throwError } from 'rxjs';
|
|
6
|
+
import { firstValueFrom } from 'rxjs';
|
|
7
|
+
|
|
8
|
+
import { kitAuthInterceptor, provideKitHttp, type KitHttpConfig } from './kit-http.interceptor';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Mock @capacitor/network so Network.getStatus() never hits native code.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
vi.mock('@capacitor/network', () => ({
|
|
14
|
+
Network: {
|
|
15
|
+
getStatus: vi.fn().mockResolvedValue({ connected: true }),
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
const baseReq = new HttpRequest<unknown>('GET', '/api/test');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a minimal KitHttpConfig with all hooks as vi.fn() no-ops; override selectively.
|
|
26
|
+
*/
|
|
27
|
+
function makeConfig(overrides: Partial<KitHttpConfig> = {}): KitHttpConfig {
|
|
28
|
+
return {
|
|
29
|
+
bypass: vi.fn().mockReturnValue(false),
|
|
30
|
+
getAuthHeaders: vi.fn().mockResolvedValue({}),
|
|
31
|
+
buildExtraHeaders: vi.fn().mockReturnValue({}),
|
|
32
|
+
offlineFallback: vi.fn().mockReturnValue(null),
|
|
33
|
+
onResponse: vi.fn(),
|
|
34
|
+
onUnauthorized: vi.fn(),
|
|
35
|
+
onForbidden: vi.fn(),
|
|
36
|
+
onNetworkError: vi.fn(),
|
|
37
|
+
onServerError: vi.fn(),
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setupInterceptor(config: KitHttpConfig) {
|
|
43
|
+
TestBed.configureTestingModule({
|
|
44
|
+
providers: [provideZonelessChangeDetection(), provideKitHttp(() => config)],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Run the interceptor inside an injection context and return the result observable. */
|
|
49
|
+
function runInterceptor(req: HttpRequest<unknown>, next: (r: HttpRequest<unknown>) => Observable<unknown>) {
|
|
50
|
+
return TestBed.runInInjectionContext(() => kitAuthInterceptor(req, next as never));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Tests
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
describe('kitAuthInterceptor', () => {
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
TestBed.resetTestingModule();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Every hook except getAuthHeaders is optional; a config that only provides
|
|
62
|
+
// getAuthHeaders must drive the pipeline without throwing on the missing hooks.
|
|
63
|
+
describe('optional hooks (only getAuthHeaders provided)', () => {
|
|
64
|
+
const minimalConfig: KitHttpConfig = { getAuthHeaders: vi.fn().mockResolvedValue({}) };
|
|
65
|
+
|
|
66
|
+
it('passes a successful response through without a bypass/onResponse hook', async () => {
|
|
67
|
+
setupInterceptor(minimalConfig);
|
|
68
|
+
const response = new HttpResponse({ status: 200, body: { ok: true } });
|
|
69
|
+
const next = vi.fn().mockReturnValue(of(response));
|
|
70
|
+
const result = await firstValueFrom(runInterceptor(baseReq, next));
|
|
71
|
+
expect(result).toBe(response);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('re-throws a 403 without an onForbidden/offlineFallback hook', async () => {
|
|
75
|
+
setupInterceptor(minimalConfig);
|
|
76
|
+
const error = new HttpErrorResponse({ status: 403 });
|
|
77
|
+
const next = vi.fn().mockReturnValue(throwError(() => error));
|
|
78
|
+
await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toBe(error);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---- bypass ---------------------------------------------------------------
|
|
83
|
+
describe('bypass', () => {
|
|
84
|
+
it('passes the original request to next without adding headers', async () => {
|
|
85
|
+
const config = makeConfig({ bypass: vi.fn().mockReturnValue(true) });
|
|
86
|
+
setupInterceptor(config);
|
|
87
|
+
|
|
88
|
+
const next = vi.fn().mockReturnValue(of(new HttpResponse({ status: 200 })));
|
|
89
|
+
await firstValueFrom(runInterceptor(baseReq, next));
|
|
90
|
+
|
|
91
|
+
expect(next).toHaveBeenCalledOnce();
|
|
92
|
+
// headers must be unmodified — the original request should be passed through
|
|
93
|
+
const passedReq: HttpRequest<unknown> = next.mock.calls[0][0];
|
|
94
|
+
expect(passedReq).toBe(baseReq);
|
|
95
|
+
expect(config.getAuthHeaders).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---- header merging -------------------------------------------------------
|
|
100
|
+
describe('header merging', () => {
|
|
101
|
+
it('merges getAuthHeaders and buildExtraHeaders onto the cloned request', async () => {
|
|
102
|
+
const config = makeConfig({
|
|
103
|
+
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: 'Bearer token' }),
|
|
104
|
+
buildExtraHeaders: vi.fn().mockReturnValue({ 'X-App-Version': '1.0' }),
|
|
105
|
+
});
|
|
106
|
+
setupInterceptor(config);
|
|
107
|
+
|
|
108
|
+
const next = vi.fn().mockReturnValue(of(new HttpResponse({ status: 200 })));
|
|
109
|
+
await firstValueFrom(runInterceptor(baseReq, next));
|
|
110
|
+
|
|
111
|
+
const passedReq: HttpRequest<unknown> = next.mock.calls[0][0];
|
|
112
|
+
expect(passedReq.headers.get('Authorization')).toBe('Bearer token');
|
|
113
|
+
expect(passedReq.headers.get('X-App-Version')).toBe('1.0');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---- offlineFallback ------------------------------------------------------
|
|
118
|
+
describe('offlineFallback', () => {
|
|
119
|
+
it('returns the fallback observable when non-null; onUnauthorized is not called', async () => {
|
|
120
|
+
const fallbackResponse = new HttpResponse({ status: 200, body: 'cached' });
|
|
121
|
+
const config = makeConfig({
|
|
122
|
+
getAuthHeaders: vi.fn().mockResolvedValue({}),
|
|
123
|
+
offlineFallback: vi.fn().mockReturnValue(of(fallbackResponse)),
|
|
124
|
+
});
|
|
125
|
+
setupInterceptor(config);
|
|
126
|
+
|
|
127
|
+
const next = vi.fn().mockReturnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
|
|
128
|
+
|
|
129
|
+
const result = await firstValueFrom(runInterceptor(baseReq, next));
|
|
130
|
+
expect(result).toBe(fallbackResponse);
|
|
131
|
+
expect(config.onUnauthorized).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---- 401 handling ---------------------------------------------------------
|
|
136
|
+
describe('401 Unauthorized', () => {
|
|
137
|
+
it('calls onUnauthorized and re-throws the error', async () => {
|
|
138
|
+
const config = makeConfig();
|
|
139
|
+
setupInterceptor(config);
|
|
140
|
+
|
|
141
|
+
const error401 = new HttpErrorResponse({ status: 401 });
|
|
142
|
+
const next = vi.fn().mockReturnValue(throwError(() => error401));
|
|
143
|
+
|
|
144
|
+
await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toThrow();
|
|
145
|
+
expect(config.onUnauthorized).toHaveBeenCalledOnce();
|
|
146
|
+
expect(config.onForbidden).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---- 403 handling ---------------------------------------------------------
|
|
151
|
+
describe('403 Forbidden', () => {
|
|
152
|
+
it('calls onForbidden and re-throws the error', async () => {
|
|
153
|
+
const config = makeConfig();
|
|
154
|
+
setupInterceptor(config);
|
|
155
|
+
|
|
156
|
+
const error403 = new HttpErrorResponse({ status: 403 });
|
|
157
|
+
const next = vi.fn().mockReturnValue(throwError(() => error403));
|
|
158
|
+
|
|
159
|
+
await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toThrow();
|
|
160
|
+
expect(config.onForbidden).toHaveBeenCalledOnce();
|
|
161
|
+
expect(config.onUnauthorized).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---- 400 with message → onServerError -------------------------------------
|
|
166
|
+
describe('400 with error.message', () => {
|
|
167
|
+
it('calls onServerError with the message and re-throws', async () => {
|
|
168
|
+
const config = makeConfig();
|
|
169
|
+
setupInterceptor(config);
|
|
170
|
+
|
|
171
|
+
const error400 = new HttpErrorResponse({ status: 400, error: { message: 'Invalid input' } });
|
|
172
|
+
const next = vi.fn().mockReturnValue(throwError(() => error400));
|
|
173
|
+
|
|
174
|
+
await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toThrow();
|
|
175
|
+
expect(config.onServerError).toHaveBeenCalledWith('Invalid input');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('does not call onServerError when error.message is absent', async () => {
|
|
179
|
+
const config = makeConfig();
|
|
180
|
+
setupInterceptor(config);
|
|
181
|
+
|
|
182
|
+
const error400 = new HttpErrorResponse({ status: 400, error: {} });
|
|
183
|
+
const next = vi.fn().mockReturnValue(throwError(() => error400));
|
|
184
|
+
|
|
185
|
+
await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toThrow();
|
|
186
|
+
expect(config.onServerError).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---- 500 with message → onServerError -------------------------------------
|
|
191
|
+
describe('500 with error.message', () => {
|
|
192
|
+
it('calls onServerError with the message', async () => {
|
|
193
|
+
const config = makeConfig();
|
|
194
|
+
setupInterceptor(config);
|
|
195
|
+
|
|
196
|
+
const error500 = new HttpErrorResponse({ status: 500, error: { message: 'Server crash' } });
|
|
197
|
+
const next = vi.fn().mockReturnValue(throwError(() => error500));
|
|
198
|
+
|
|
199
|
+
await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toThrow();
|
|
200
|
+
expect(config.onServerError).toHaveBeenCalledWith('Server crash');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---- NON_RETRYABLE: no extra subscriptions --------------------------------
|
|
205
|
+
describe('retry: NON_RETRYABLE statuses are not retried', () => {
|
|
206
|
+
it('subscribes to the source observable exactly once for status 400', async () => {
|
|
207
|
+
const config = makeConfig();
|
|
208
|
+
setupInterceptor(config);
|
|
209
|
+
|
|
210
|
+
let subscriptionCount = 0;
|
|
211
|
+
const next = vi.fn().mockImplementation(() => {
|
|
212
|
+
return new Observable((subscriber) => {
|
|
213
|
+
subscriptionCount++;
|
|
214
|
+
subscriber.error(new HttpErrorResponse({ status: 400, error: {} }));
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toThrow();
|
|
219
|
+
expect(subscriptionCount).toBe(1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('subscribes exactly once for status 401 (immediate throw, no retry)', async () => {
|
|
223
|
+
const config = makeConfig();
|
|
224
|
+
setupInterceptor(config);
|
|
225
|
+
|
|
226
|
+
let subscriptionCount = 0;
|
|
227
|
+
const next = vi.fn().mockImplementation(() => {
|
|
228
|
+
return new Observable((subscriber) => {
|
|
229
|
+
subscriptionCount++;
|
|
230
|
+
subscriber.error(new HttpErrorResponse({ status: 401 }));
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toThrow();
|
|
235
|
+
expect(subscriptionCount).toBe(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('subscribes exactly once for status 403', async () => {
|
|
239
|
+
const config = makeConfig();
|
|
240
|
+
setupInterceptor(config);
|
|
241
|
+
|
|
242
|
+
let subscriptionCount = 0;
|
|
243
|
+
const next = vi.fn().mockImplementation(() => {
|
|
244
|
+
return new Observable((subscriber) => {
|
|
245
|
+
subscriptionCount++;
|
|
246
|
+
subscriber.error(new HttpErrorResponse({ status: 403 }));
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toThrow();
|
|
251
|
+
expect(subscriptionCount).toBe(1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('subscribes exactly once for status 404', async () => {
|
|
255
|
+
const config = makeConfig();
|
|
256
|
+
setupInterceptor(config);
|
|
257
|
+
|
|
258
|
+
let subscriptionCount = 0;
|
|
259
|
+
const next = vi.fn().mockImplementation(() => {
|
|
260
|
+
return new Observable((subscriber) => {
|
|
261
|
+
subscriptionCount++;
|
|
262
|
+
subscriber.error(new HttpErrorResponse({ status: 404 }));
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toThrow();
|
|
267
|
+
expect(subscriptionCount).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ---- success path ---------------------------------------------------------
|
|
272
|
+
describe('success path', () => {
|
|
273
|
+
it('emits the response when next succeeds', async () => {
|
|
274
|
+
const config = makeConfig();
|
|
275
|
+
setupInterceptor(config);
|
|
276
|
+
|
|
277
|
+
const response = new HttpResponse({ status: 200, body: 'ok' });
|
|
278
|
+
const next = vi.fn().mockReturnValue(of(response));
|
|
279
|
+
|
|
280
|
+
const result = await firstValueFrom(runInterceptor(baseReq, next));
|
|
281
|
+
expect(result).toBe(response);
|
|
282
|
+
expect(config.onResponse).toHaveBeenCalledWith(response);
|
|
283
|
+
expect(config.onUnauthorized).not.toHaveBeenCalled();
|
|
284
|
+
expect(config.onForbidden).not.toHaveBeenCalled();
|
|
285
|
+
expect(config.onServerError).not.toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('does not call onResponse for an offlineFallback-synthesized response', async () => {
|
|
289
|
+
const fallbackResponse = new HttpResponse({ status: 201, body: { mode: 'offline' } });
|
|
290
|
+
const config = makeConfig({ offlineFallback: vi.fn().mockReturnValue(of(fallbackResponse)) });
|
|
291
|
+
setupInterceptor(config);
|
|
292
|
+
|
|
293
|
+
// status 400 is non-retryable → catchError fires immediately (no retry delay)
|
|
294
|
+
const next = vi.fn().mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 })));
|
|
295
|
+
const result = await firstValueFrom(runInterceptor(baseReq, next));
|
|
296
|
+
expect(result).toBe(fallbackResponse);
|
|
297
|
+
expect(config.onResponse).not.toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { EnvironmentProviders } from '@angular/core';
|
|
2
|
+
import { inject, InjectionToken, makeEnvironmentProviders } from '@angular/core';
|
|
3
|
+
import type { HttpErrorResponse, HttpEvent, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
|
|
4
|
+
import { HttpResponse } from '@angular/common/http';
|
|
5
|
+
import { Network } from '@capacitor/network';
|
|
6
|
+
import type { Observable } from 'rxjs';
|
|
7
|
+
import { from, retry, throwError, timer } from 'rxjs';
|
|
8
|
+
import { catchError, mergeMap, tap } from 'rxjs/operators';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* HTTP status codes that must never be retried. `401` is handled separately and thrown immediately.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
const NON_RETRYABLE_STATUSES = [400, 403, 404, 418, 500, 502];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Configuration that customizes the behavior of {@link kitAuthInterceptor}, injected through {@link provideKitHttp}.
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* The interceptor fixes the retry policy (up to 2 retries with a linearly increasing backoff, plus immediate
|
|
22
|
+
* throw on `401` and on every {@link NON_RETRYABLE_STATUSES | non-retryable status}) and the overall
|
|
23
|
+
* control flow. Only the hooks below are application-specific.
|
|
24
|
+
*
|
|
25
|
+
* Only {@link KitHttpConfig.getAuthHeaders} is required — it has no safe default. Every other hook is
|
|
26
|
+
* optional and defaults to a no-op (or `{}` / `false` / `null` as appropriate), so an app configures
|
|
27
|
+
* only the behavior that actually differs from the canonical baseline.
|
|
28
|
+
*/
|
|
29
|
+
export interface KitHttpConfig {
|
|
30
|
+
/**
|
|
31
|
+
* Produce authentication and metadata headers for the outgoing request.
|
|
32
|
+
*
|
|
33
|
+
* @param request - The outgoing request about to be sent.
|
|
34
|
+
* @returns A map of header names to values, resolved asynchronously.
|
|
35
|
+
*/
|
|
36
|
+
getAuthHeaders(request: HttpRequest<unknown>): Promise<Record<string, string>>;
|
|
37
|
+
/**
|
|
38
|
+
* Produce additional headers for the outgoing request.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* Optional; defaults to adding no extra headers.
|
|
42
|
+
*
|
|
43
|
+
* @param request - The outgoing request about to be sent.
|
|
44
|
+
* @returns A map of header names to values; return `{}` when none are needed.
|
|
45
|
+
*/
|
|
46
|
+
buildExtraHeaders?(request: HttpRequest<unknown>): Record<string, string>;
|
|
47
|
+
/**
|
|
48
|
+
* Called for every successful response that completed an actual network round trip.
|
|
49
|
+
*
|
|
50
|
+
* @remarks
|
|
51
|
+
* Responses synthesized by {@link KitHttpConfig.offlineFallback} are produced after `catchError`
|
|
52
|
+
* and therefore never reach this hook, so it observes genuine successes only. A typical use is to
|
|
53
|
+
* reset an "offline" flag once connectivity is restored. Optional; defaults to a no-op.
|
|
54
|
+
*
|
|
55
|
+
* @param event - The successful `HttpResponse`.
|
|
56
|
+
*/
|
|
57
|
+
onResponse?(event: HttpResponse<unknown>): void;
|
|
58
|
+
/**
|
|
59
|
+
* Decide whether to pass the request straight through, skipping auth, retry, and error handling.
|
|
60
|
+
*
|
|
61
|
+
* @remarks
|
|
62
|
+
* Useful for external URLs such as S3 or a CDN. Optional; defaults to `false` (never bypass).
|
|
63
|
+
*
|
|
64
|
+
* @param request - The outgoing request.
|
|
65
|
+
* @returns `true` to bypass the interceptor pipeline.
|
|
66
|
+
*/
|
|
67
|
+
bypass?(request: HttpRequest<unknown>): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Provide an offline short-circuit when a request fails.
|
|
70
|
+
*
|
|
71
|
+
* @remarks
|
|
72
|
+
* Returning a non-null observable replaces the error with that response (for example a queued
|
|
73
|
+
* offline result). Optional; defaults to `null` (no fallback, normal error handling proceeds).
|
|
74
|
+
*
|
|
75
|
+
* @param request - The request that failed (after headers were applied).
|
|
76
|
+
* @param error - The error response that triggered the fallback.
|
|
77
|
+
* @returns A replacement event stream, or `null` for no fallback.
|
|
78
|
+
*/
|
|
79
|
+
offlineFallback?(request: HttpRequest<unknown>, error: HttpErrorResponse): Observable<HttpEvent<unknown>> | null;
|
|
80
|
+
/**
|
|
81
|
+
* Side effect to run on a `401` response (for example an expired token).
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* Optional; defaults to a no-op.
|
|
85
|
+
*
|
|
86
|
+
* @param request - The request that received the `401`.
|
|
87
|
+
*/
|
|
88
|
+
onUnauthorized?(request: HttpRequest<unknown>): void;
|
|
89
|
+
/**
|
|
90
|
+
* Side effect to run on a `403` response (a permission error).
|
|
91
|
+
*
|
|
92
|
+
* @remarks
|
|
93
|
+
* Optional; defaults to a no-op.
|
|
94
|
+
*
|
|
95
|
+
* @param request - The request that received the `403`.
|
|
96
|
+
*/
|
|
97
|
+
onForbidden?(request: HttpRequest<unknown>): void;
|
|
98
|
+
/**
|
|
99
|
+
* UX hook for network-originated errors while the device is connected.
|
|
100
|
+
*
|
|
101
|
+
* @remarks
|
|
102
|
+
* Optional; defaults to a no-op. The kit ships {@link KitReloadAlertController} as the fleet's
|
|
103
|
+
* canonical implementation of this hook (with auto-dismiss on reconnect via `onResponse`).
|
|
104
|
+
*
|
|
105
|
+
* @param status - The HTTP status code, or a string descriptor for non-HTTP failures.
|
|
106
|
+
* @returns Optionally a promise to await before continuing.
|
|
107
|
+
*/
|
|
108
|
+
onNetworkError?(status: number | string): Promise<void> | void;
|
|
109
|
+
/**
|
|
110
|
+
* UX hook for `400` / `500` responses that carry a server-provided message.
|
|
111
|
+
*
|
|
112
|
+
* @remarks
|
|
113
|
+
* Optional; defaults to a no-op.
|
|
114
|
+
*
|
|
115
|
+
* @param message - The message extracted from the error body.
|
|
116
|
+
*/
|
|
117
|
+
onServerError?(message: string): void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Injection token that carries the {@link KitHttpConfig} to {@link kitAuthInterceptor}.
|
|
122
|
+
*/
|
|
123
|
+
export const KIT_HTTP_CONFIG = new InjectionToken<KitHttpConfig>('@rdlabo/ionic-angular-kit:http');
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Wire the {@link kitAuthInterceptor} configuration into the application's dependency injection.
|
|
127
|
+
*
|
|
128
|
+
* @remarks
|
|
129
|
+
* Register the interceptor itself separately via `provideHttpClient(withInterceptors([kitAuthInterceptor]))`.
|
|
130
|
+
* The factory runs inside an injection context, so it may call `inject()`.
|
|
131
|
+
*
|
|
132
|
+
* @param configFactory - Factory that returns the {@link KitHttpConfig} for the application.
|
|
133
|
+
* @returns Environment providers to add to the application bootstrap.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* bootstrapApplication(AppComponent, {
|
|
138
|
+
* providers: [
|
|
139
|
+
* provideHttpClient(withInterceptors([kitAuthInterceptor])),
|
|
140
|
+
* provideKitHttp(() => {
|
|
141
|
+
* const auth = inject(AuthService);
|
|
142
|
+
* const reload = inject(KitReloadAlertController);
|
|
143
|
+
* return {
|
|
144
|
+
* // Only getAuthHeaders is required; every other hook is optional and defaults to a no-op.
|
|
145
|
+
* getAuthHeaders: async () => ({ Authorization: `Bearer ${await auth.token()}` }),
|
|
146
|
+
* onUnauthorized: () => auth.signOut(),
|
|
147
|
+
* onNetworkError: (status) =>
|
|
148
|
+
* reload.present({ header: 'Network error', message: `Reload? (${status})`, okText: 'Reload' }),
|
|
149
|
+
* onResponse: () => void reload.dismiss(),
|
|
150
|
+
* };
|
|
151
|
+
* }),
|
|
152
|
+
* ],
|
|
153
|
+
* });
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export const provideKitHttp = (configFactory: () => KitHttpConfig): EnvironmentProviders =>
|
|
157
|
+
makeEnvironmentProviders([{ provide: KIT_HTTP_CONFIG, useFactory: configFactory }]);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Canonical functional HTTP interceptor that applies authentication, retries, and error handling.
|
|
161
|
+
*
|
|
162
|
+
* @remarks
|
|
163
|
+
* Behavior, driven by the injected {@link KitHttpConfig}:
|
|
164
|
+
*
|
|
165
|
+
* 1. Requests for which `bypass` returns `true` are forwarded untouched.
|
|
166
|
+
* 2. Otherwise the headers from `getAuthHeaders` and `buildExtraHeaders` are merged onto a cloned request.
|
|
167
|
+
* 3. Failed requests are retried up to 2 times with a linearly increasing backoff of `500ms * (retryCount + 5)`,
|
|
168
|
+
* except that `401` and any {@link NON_RETRYABLE_STATUSES | non-retryable status}
|
|
169
|
+
* (`400`, `403`, `404`, `418`, `500`, `502`) are thrown immediately without retrying.
|
|
170
|
+
* 4. On error, `offlineFallback` is consulted first; otherwise `401` calls `onUnauthorized`, `403`
|
|
171
|
+
* calls `onForbidden`, network-class failures (anything other than `400`/`500`) call
|
|
172
|
+
* `onNetworkError` when the device is connected, and `400`/`500` responses carrying a body
|
|
173
|
+
* message call `onServerError`.
|
|
174
|
+
*
|
|
175
|
+
* @param request - The outgoing request.
|
|
176
|
+
* @param next - The next handler in the interceptor chain.
|
|
177
|
+
* @returns A stream of HTTP events for the (possibly modified, retried, or replaced) request.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```ts
|
|
181
|
+
* provideHttpClient(withInterceptors([kitAuthInterceptor]));
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
export const kitAuthInterceptor: HttpInterceptorFn = (request, next) => {
|
|
185
|
+
const config = inject(KIT_HTTP_CONFIG);
|
|
186
|
+
|
|
187
|
+
if (config.bypass?.(request)) {
|
|
188
|
+
return next(request);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return from(config.getAuthHeaders(request)).pipe(
|
|
192
|
+
mergeMap((authHeaders) => {
|
|
193
|
+
const req = request.clone({ setHeaders: { ...authHeaders, ...config.buildExtraHeaders?.(request) } });
|
|
194
|
+
|
|
195
|
+
return next(req).pipe(
|
|
196
|
+
retry({
|
|
197
|
+
count: 2,
|
|
198
|
+
delay: (e: HttpErrorResponse, retryCount) => {
|
|
199
|
+
if (e.status === 401) {
|
|
200
|
+
return throwError(() => e);
|
|
201
|
+
}
|
|
202
|
+
if (NON_RETRYABLE_STATUSES.includes(e.status)) {
|
|
203
|
+
return throwError(() => e);
|
|
204
|
+
}
|
|
205
|
+
return timer((retryCount + 5) * 500);
|
|
206
|
+
},
|
|
207
|
+
}),
|
|
208
|
+
tap((event) => {
|
|
209
|
+
if (event instanceof HttpResponse) {
|
|
210
|
+
config.onResponse?.(event);
|
|
211
|
+
}
|
|
212
|
+
}),
|
|
213
|
+
catchError((e: HttpErrorResponse) => {
|
|
214
|
+
const fallback = config.offlineFallback?.(req, e);
|
|
215
|
+
if (fallback) {
|
|
216
|
+
return fallback;
|
|
217
|
+
}
|
|
218
|
+
if (e.status === 401) {
|
|
219
|
+
config.onUnauthorized?.(req);
|
|
220
|
+
} else if (e.status === 403) {
|
|
221
|
+
config.onForbidden?.(req);
|
|
222
|
+
} else if (![400, 500].includes(e.status)) {
|
|
223
|
+
void Network.getStatus().then((status) => {
|
|
224
|
+
if (status.connected) {
|
|
225
|
+
config.onNetworkError?.(e.status);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
} else if (e.error?.message) {
|
|
229
|
+
config.onServerError?.(e.error.message);
|
|
230
|
+
}
|
|
231
|
+
return throwError(() => e);
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
};
|