@equinor/fusion-framework-module-http 8.0.4 → 8.1.0-next.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/CHANGELOG.md +70 -0
- package/README.md +7 -0
- package/dist/esm/configurator.js +9 -1
- package/dist/esm/configurator.js.map +1 -1
- package/dist/esm/errors/HttpJsonResponseError.js +1 -0
- package/dist/esm/errors/HttpJsonResponseError.js.map +1 -1
- package/dist/esm/lib/client/client.js +50 -7
- package/dist/esm/lib/client/client.js.map +1 -1
- package/dist/esm/lib/operators/HttpMiddlewareHandler.js +45 -0
- package/dist/esm/lib/operators/HttpMiddlewareHandler.js.map +1 -0
- package/dist/esm/lib/operators/index.js +1 -0
- package/dist/esm/lib/operators/index.js.map +1 -1
- package/dist/esm/mock/create-open-api-mock-middleware.js +33 -0
- package/dist/esm/mock/create-open-api-mock-middleware.js.map +1 -0
- package/dist/esm/mock/create-router-middleware.js +97 -0
- package/dist/esm/mock/create-router-middleware.js.map +1 -0
- package/dist/esm/mock/index.js +20 -0
- package/dist/esm/mock/index.js.map +1 -0
- package/dist/esm/mock/resolve-open-api-mock-response.js +20 -0
- package/dist/esm/mock/resolve-open-api-mock-response.js.map +1 -0
- package/dist/esm/provider.js +9 -2
- package/dist/esm/provider.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/esm/version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/configurator.d.ts +29 -1
- package/dist/types/errors/HttpJsonResponseError.d.ts +1 -0
- package/dist/types/lib/client/client.d.ts +26 -2
- package/dist/types/lib/operators/HttpMiddlewareHandler.d.ts +24 -0
- package/dist/types/lib/operators/index.d.ts +1 -0
- package/dist/types/lib/operators/types.d.ts +74 -1
- package/dist/types/mock/create-open-api-mock-middleware.d.ts +28 -0
- package/dist/types/mock/create-router-middleware.d.ts +73 -0
- package/dist/types/mock/index.d.ts +20 -0
- package/dist/types/mock/resolve-open-api-mock-response.d.ts +27 -0
- package/dist/types/provider.d.ts +11 -1
- package/dist/types/version.d.ts +1 -1
- package/docs/testing.md +78 -0
- package/package.json +11 -4
- package/src/configurator.ts +42 -1
- package/src/errors/HttpJsonResponseError.ts +1 -0
- package/src/lib/client/client.ts +59 -7
- package/src/lib/operators/HttpMiddlewareHandler.ts +58 -0
- package/src/lib/operators/index.ts +1 -0
- package/src/lib/operators/types.ts +87 -1
- package/src/mock/create-open-api-mock-middleware.ts +40 -0
- package/src/mock/create-router-middleware.ts +158 -0
- package/src/mock/index.ts +26 -0
- package/src/mock/resolve-open-api-mock-response.ts +36 -0
- package/src/provider.ts +17 -2
- package/src/version.ts +1 -1
- package/tests/HttpClient.test.ts +46 -0
- package/tests/HttpMiddlewareHandler.test.ts +58 -0
- package/tests/mock/adapters.test.ts +62 -0
- package/tests/mock/router-middleware.test.ts +135 -0
- package/vitest.config.ts +1 -1
|
@@ -12,6 +12,7 @@ export class HttpJsonResponseError<
|
|
|
12
12
|
TResponse = Response,
|
|
13
13
|
> extends HttpResponseError<TResponse> {
|
|
14
14
|
static Name = 'HttpJsonResponseError';
|
|
15
|
+
/** The parsed JSON data associated with the error response, if any. */
|
|
15
16
|
public readonly data?: TType;
|
|
16
17
|
|
|
17
18
|
/**
|
package/src/lib/client/client.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { firstValueFrom, of, Subject } from 'rxjs';
|
|
2
|
-
import { switchMap, takeUntil, tap } from 'rxjs/operators';
|
|
1
|
+
import { finalize, firstValueFrom, of, Subject } from 'rxjs';
|
|
2
|
+
import { switchMap, take, takeUntil, tap } from 'rxjs/operators';
|
|
3
3
|
import { fromFetch } from 'rxjs/fetch';
|
|
4
4
|
|
|
5
|
-
import { HttpRequestHandler, HttpResponseHandler } from '../operators';
|
|
5
|
+
import { HttpMiddlewareHandler, HttpRequestHandler, HttpResponseHandler } from '../operators';
|
|
6
6
|
import { blobSelector, jsonSelector } from '../selectors';
|
|
7
7
|
|
|
8
8
|
import type { Observable, ObservableInput } from 'rxjs';
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
IHttpMiddlewareHandler,
|
|
11
|
+
IHttpRequestHandler,
|
|
12
|
+
IHttpResponseHandler,
|
|
13
|
+
} from '../operators';
|
|
10
14
|
import type {
|
|
11
15
|
BlobResult,
|
|
12
16
|
FetchRequest,
|
|
@@ -38,6 +42,7 @@ export type HttpClientCreateOptions<
|
|
|
38
42
|
> = {
|
|
39
43
|
requestHandler: IHttpRequestHandler<TRequest>;
|
|
40
44
|
responseHandler: IHttpResponseHandler<TResponse>;
|
|
45
|
+
middlewareHandler: IHttpMiddlewareHandler;
|
|
41
46
|
};
|
|
42
47
|
|
|
43
48
|
/** Base http client for executing requests */
|
|
@@ -58,6 +63,13 @@ export class HttpClient<
|
|
|
58
63
|
*/
|
|
59
64
|
public readonly responseHandler: IHttpResponseHandler<TResponse>;
|
|
60
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Middleware wrapping the network call, for cross-cutting concerns such as retries,
|
|
68
|
+
* caching, or telemetry. This property is part of the `HttpClientCreateOptions`
|
|
69
|
+
* configuration object used to create an `HttpClient` instance.
|
|
70
|
+
*/
|
|
71
|
+
public readonly middlewareHandler: IHttpMiddlewareHandler;
|
|
72
|
+
|
|
61
73
|
/**
|
|
62
74
|
* A stream of requests that are about to be executed.
|
|
63
75
|
* This property is used internally by the `HttpClient` class to manage the lifecycle of requests.
|
|
@@ -103,6 +115,7 @@ export class HttpClient<
|
|
|
103
115
|
) {
|
|
104
116
|
this.requestHandler = new HttpRequestHandler<TRequest>(options?.requestHandler);
|
|
105
117
|
this.responseHandler = new HttpResponseHandler<TResponse>(options?.responseHandler);
|
|
118
|
+
this.middlewareHandler = new HttpMiddlewareHandler(options?.middlewareHandler);
|
|
106
119
|
this._init();
|
|
107
120
|
}
|
|
108
121
|
|
|
@@ -326,7 +339,9 @@ export class HttpClient<
|
|
|
326
339
|
/**
|
|
327
340
|
* Aborts any ongoing HTTP requests made by this `IHttpClient` instance.
|
|
328
341
|
* This will trigger the `takeUntil` operator in the `_fetch$` method,
|
|
329
|
-
* causing any in-flight requests to be cancelled
|
|
342
|
+
* causing any in-flight requests to be cancelled, and abort the
|
|
343
|
+
* per-request `AbortSignal` passed through to `_performFetch`, so the
|
|
344
|
+
* underlying network call is cancelled even behind registered middleware.
|
|
330
345
|
*/
|
|
331
346
|
public abort(): void {
|
|
332
347
|
this._abort$.next();
|
|
@@ -355,11 +370,26 @@ export class HttpClient<
|
|
|
355
370
|
args?: FetchRequestInit<T, TRequest, TResponse>,
|
|
356
371
|
): Observable<T> {
|
|
357
372
|
const { selector, ...options } = args || {};
|
|
373
|
+
// A registered middleware's `next(...)` resolves through a `Promise` (see
|
|
374
|
+
// `HttpMiddlewareHandler`), which `firstValueFrom` fulfils via its own independent
|
|
375
|
+
// subscription to `_performFetch` — one the `takeUntil(this._abort$)` below never reaches,
|
|
376
|
+
// since it sits outside the subscription tree that `takeUntil` tears down. Combining this
|
|
377
|
+
// controller's signal into the request `init` lets `_performFetch` (`fromFetch` by default)
|
|
378
|
+
// abort the underlying network call directly, regardless of whether middleware severed the
|
|
379
|
+
// RxJS teardown chain.
|
|
380
|
+
const abortController = new AbortController();
|
|
381
|
+
// abort only fires once per request; the subscription is torn down in `finalize` below
|
|
382
|
+
const abort = this._abort$.pipe(take(1)).subscribe(() => abortController.abort());
|
|
383
|
+
const callerSignal = (options as RequestInit).signal;
|
|
384
|
+
const signal = callerSignal
|
|
385
|
+
? AbortSignal.any([callerSignal, abortController.signal])
|
|
386
|
+
: abortController.signal;
|
|
358
387
|
// `fromFetch` yields the raw fetch `Response`, but `responseHandler.process()` (called via
|
|
359
388
|
// `_prepareResponse`) expects the pipeline's generic `TResponse` shape — cast through
|
|
360
389
|
// `unknown` since the two are only compatible after that processing step.
|
|
361
390
|
const response$ = of({
|
|
362
391
|
...options,
|
|
392
|
+
signal,
|
|
363
393
|
path,
|
|
364
394
|
uri: this._resolveUrl(path),
|
|
365
395
|
} as TRequest).pipe(
|
|
@@ -367,8 +397,10 @@ export class HttpClient<
|
|
|
367
397
|
switchMap((x) => this._prepareRequest(x)),
|
|
368
398
|
/** push request to event buss */
|
|
369
399
|
tap((x) => this._request$.next(x)),
|
|
370
|
-
/** execute request */
|
|
371
|
-
switchMap(({ uri, path: _path, ...init }) =>
|
|
400
|
+
/** execute request through registered middleware, terminating at _performFetch */
|
|
401
|
+
switchMap(({ uri, path: _path, ...init }) =>
|
|
402
|
+
this.middlewareHandler.process(uri, init, (u, i) => this._performFetch(u, i)),
|
|
403
|
+
),
|
|
372
404
|
/** prepare response, allow extensions to modify response */
|
|
373
405
|
switchMap((x) => this._prepareResponse(x as unknown as TResponse)),
|
|
374
406
|
/** push response to event buss */
|
|
@@ -394,12 +426,32 @@ export class HttpClient<
|
|
|
394
426
|
}),
|
|
395
427
|
/** cancel request on abort signal */
|
|
396
428
|
takeUntil(this._abort$),
|
|
429
|
+
/** the abort signal subscription only ever fires once; tear it down once this request settles either way */
|
|
430
|
+
finalize(() => abort.unsubscribe()),
|
|
397
431
|
);
|
|
398
432
|
// The pipe above resolves to the per-call generic `T` (via the optional `selector`), but
|
|
399
433
|
// the observable's static type tracks the class-level `TResponse` — cast to the caller's `T`.
|
|
400
434
|
return response$ as unknown as Observable<T>;
|
|
401
435
|
}
|
|
402
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Performs the actual network call for a prepared request.
|
|
439
|
+
*
|
|
440
|
+
* @remarks
|
|
441
|
+
* Isolated from {@link _fetch$} so a test double can replace only this step —
|
|
442
|
+
* matching a request against registered route handlers instead of reaching
|
|
443
|
+
* the network — while everything around it (request preparation, the
|
|
444
|
+
* response pipeline, abort handling) runs unchanged. See
|
|
445
|
+
* `@equinor/fusion-framework-module-http/mock`.
|
|
446
|
+
*
|
|
447
|
+
* @param uri - The fully resolved URL for the request.
|
|
448
|
+
* @param init - The prepared `fetch` request options.
|
|
449
|
+
* @returns An observable of the raw `Response`, ahead of {@link _prepareResponse}.
|
|
450
|
+
*/
|
|
451
|
+
protected _performFetch(uri: string, init: RequestInit): ObservableInput<Response> {
|
|
452
|
+
return fromFetch(uri, init);
|
|
453
|
+
}
|
|
454
|
+
|
|
403
455
|
/**
|
|
404
456
|
* Prepares the request by passing it through the `requestHandler.process()` method.
|
|
405
457
|
* This method is an implementation detail of the `_fetch$()` method, and is not part of the public API.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { firstValueFrom, from, of } from 'rxjs';
|
|
2
|
+
import type { Observable, ObservableInput } from 'rxjs';
|
|
3
|
+
|
|
4
|
+
import type { HttpMiddleware, HttpMiddlewareNext, IHttpMiddlewareHandler } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalizes a step's result to a `Promise`, so a middleware calling `next(...)` never has to
|
|
8
|
+
* branch on whether the next step short-circuited with a plain `Response` or reached all the
|
|
9
|
+
* way to an Observable-returning network call.
|
|
10
|
+
*/
|
|
11
|
+
function toPromise(result: Response | ObservableInput<Response>): Promise<Response> {
|
|
12
|
+
return result instanceof Response ? Promise.resolve(result) : firstValueFrom(from(result));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Composes registered {@link HttpMiddleware} into a single execution pipeline wrapping the
|
|
17
|
+
* network call, so retries, caching, telemetry, or circuit-breaking can wrap `_performFetch`
|
|
18
|
+
* without touching request or response payload transforms.
|
|
19
|
+
*
|
|
20
|
+
* @see {@link HttpClient}
|
|
21
|
+
*/
|
|
22
|
+
export class HttpMiddlewareHandler implements IHttpMiddlewareHandler {
|
|
23
|
+
#middleware: HttpMiddleware[];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Constructs a handler, optionally cloning another handler's registered middleware.
|
|
27
|
+
* @param source - An existing handler to clone the registered middleware from.
|
|
28
|
+
*/
|
|
29
|
+
constructor(source?: IHttpMiddlewareHandler) {
|
|
30
|
+
this.#middleware = source ? [...source.middleware] : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @inheritdoc */
|
|
34
|
+
get middleware(): readonly HttpMiddleware[] {
|
|
35
|
+
return this.#middleware;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @inheritdoc */
|
|
39
|
+
use(middleware: HttpMiddleware): HttpMiddlewareHandler {
|
|
40
|
+
this.#middleware.push(middleware);
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @inheritdoc */
|
|
45
|
+
process(uri: string, init: RequestInit, terminal: HttpMiddlewareNext): Observable<Response> {
|
|
46
|
+
// wrap outward-in so the first-registered middleware is outermost, matching a conventional middleware chain
|
|
47
|
+
const chain = this.#middleware.reduceRight<HttpMiddlewareNext>(
|
|
48
|
+
(next, middleware) => (nextUri, nextInit) =>
|
|
49
|
+
middleware(nextUri, nextInit, (u, i) => toPromise(next(u, i))),
|
|
50
|
+
terminal,
|
|
51
|
+
);
|
|
52
|
+
const result = chain(uri, init);
|
|
53
|
+
// a middleware may short-circuit with a plain Response (no ObservableInput wrapping needed)
|
|
54
|
+
return result instanceof Response ? of(result) : from(result);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default HttpMiddlewareHandler;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { HttpRequestHandler } from './HttpRequestHandler';
|
|
2
2
|
export { HttpResponseHandler } from './HttpResponseHandler';
|
|
3
|
+
export { HttpMiddlewareHandler } from './HttpMiddlewareHandler';
|
|
3
4
|
export { ProcessOperators } from './ProcessOperators';
|
|
4
5
|
export { capitalizeRequestMethodOperator } from './capitalize-request-method-operator';
|
|
5
6
|
export { requestValidationOperator } from './request-validation-operator';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Observable } from 'rxjs';
|
|
1
|
+
import type { Observable, ObservableInput } from 'rxjs';
|
|
2
2
|
import type { FetchRequest } from '../client';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -86,3 +86,89 @@ export interface IHttpRequestHandler<T extends FetchRequest = FetchRequest>
|
|
|
86
86
|
* @template T - The type of the response being processed. Defaults to `Response`.
|
|
87
87
|
*/
|
|
88
88
|
export interface IHttpResponseHandler<T = Response> extends IProcessOperators<T> {}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Continues an HTTP request by resolving the given (already-processed) request into a response.
|
|
92
|
+
*
|
|
93
|
+
* @remarks
|
|
94
|
+
* The terminal `next` passed to the outermost {@link HttpMiddleware} ultimately resolves to
|
|
95
|
+
* `HttpClient._performFetch` — the same overridable seam the mock system replaces — so
|
|
96
|
+
* middleware wraps around either the real network call or a mocked one transparently.
|
|
97
|
+
*
|
|
98
|
+
* @param uri - The fully resolved URL for the request.
|
|
99
|
+
* @param init - The prepared `fetch` request options.
|
|
100
|
+
* @returns The resulting `Response`, or an observable input of it.
|
|
101
|
+
*/
|
|
102
|
+
export type HttpMiddlewareNext = (
|
|
103
|
+
uri: string,
|
|
104
|
+
init: RequestInit,
|
|
105
|
+
) => Response | ObservableInput<Response>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Continues to the next registered {@link HttpMiddleware}, or the network call itself,
|
|
109
|
+
* always resolving to a `Response` regardless of how that next step actually produced it —
|
|
110
|
+
* a short-circuited `Response`, a `Promise`, or an `Observable`.
|
|
111
|
+
*
|
|
112
|
+
* @param uri - The fully resolved URL for the request.
|
|
113
|
+
* @param init - The prepared `fetch` request options.
|
|
114
|
+
* @returns A promise of the resulting `Response`.
|
|
115
|
+
*/
|
|
116
|
+
export type HttpMiddlewareContinuation = (uri: string, init: RequestInit) => Promise<Response>;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Wraps request execution to add cross-cutting behavior — retries, caching, telemetry,
|
|
120
|
+
* circuit breaking — around the network call itself, rather than transforming the
|
|
121
|
+
* request or response payload.
|
|
122
|
+
*
|
|
123
|
+
* @remarks
|
|
124
|
+
* Unlike {@link ProcessOperator}, which transforms a value in a linear pipeline, a middleware
|
|
125
|
+
* controls whether and how many times `next` runs: it can short-circuit by never calling
|
|
126
|
+
* `next`, retry by calling it more than once, or recover from a rejection it throws.
|
|
127
|
+
* Registered middleware compose in an "onion" — the first one registered is outermost, so it
|
|
128
|
+
* sees the request first and the response last.
|
|
129
|
+
*
|
|
130
|
+
* @param uri - The fully resolved URL for the request.
|
|
131
|
+
* @param init - The prepared `fetch` request options.
|
|
132
|
+
* @param next - Continues to the next registered middleware, or the network call itself.
|
|
133
|
+
* @returns The resulting `Response`, or an observable input of it.
|
|
134
|
+
*
|
|
135
|
+
* @example Retry once on a failed response
|
|
136
|
+
* ```typescript
|
|
137
|
+
* const retryOnce: HttpMiddleware = async (uri, init, next) => {
|
|
138
|
+
* const response = await next(uri, init);
|
|
139
|
+
* return response.ok ? response : next(uri, init);
|
|
140
|
+
* };
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export type HttpMiddleware = (
|
|
144
|
+
uri: string,
|
|
145
|
+
init: RequestInit,
|
|
146
|
+
next: HttpMiddlewareContinuation,
|
|
147
|
+
) => Response | ObservableInput<Response>;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Registers and composes {@link HttpMiddleware} into a single execution pipeline wrapping
|
|
151
|
+
* the network call.
|
|
152
|
+
*/
|
|
153
|
+
export interface IHttpMiddlewareHandler {
|
|
154
|
+
/**
|
|
155
|
+
* Gets the registered middleware, in registration order.
|
|
156
|
+
*/
|
|
157
|
+
get middleware(): readonly HttpMiddleware[];
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Registers a middleware, wrapping every middleware registered before it.
|
|
161
|
+
* @param middleware - The middleware to register.
|
|
162
|
+
* @returns The updated handler, for chaining.
|
|
163
|
+
*/
|
|
164
|
+
use(middleware: HttpMiddleware): IHttpMiddlewareHandler;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Runs the registered middleware chain around a request, ending with `terminal`.
|
|
168
|
+
* @param uri - The fully resolved URL for the request.
|
|
169
|
+
* @param init - The prepared `fetch` request options.
|
|
170
|
+
* @param terminal - The innermost step the chain wraps, called when every middleware defers to `next`.
|
|
171
|
+
* @returns An observable of the resulting `Response`.
|
|
172
|
+
*/
|
|
173
|
+
process(uri: string, init: RequestInit, terminal: HttpMiddlewareNext): Observable<Response>;
|
|
174
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HttpMiddleware } from '../lib/operators/types';
|
|
2
|
+
|
|
3
|
+
import { resolveOpenApiMockResponse, type OpenApiMockLike } from './resolve-open-api-mock-response';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Adapts an `OpenApiMock` into an {@link HttpMiddleware}, so
|
|
7
|
+
* `configurator.http.addMiddleware(...)` fakes every matching request
|
|
8
|
+
* straight from an OpenAPI document — no separate mock configurator needed.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* A request that matches no operation in the document falls through to
|
|
12
|
+
* `next`, so this composes with whatever else is registered — including the
|
|
13
|
+
* real network call, or another middleware further down the chain. Because
|
|
14
|
+
* `addMiddleware` wraps `_performFetch` rather than replacing it, the exact
|
|
15
|
+
* same registration also fakes requests through any client this configurator
|
|
16
|
+
* builds, so app config never has to branch on whether it's under test.
|
|
17
|
+
*
|
|
18
|
+
* @param openApiMock - Typically `createOpenApiMock(document)` from `@equinor/fusion-openapi-mock`.
|
|
19
|
+
* @returns A middleware for {@link IHttpClientConfigurator.addMiddleware}.
|
|
20
|
+
*
|
|
21
|
+
* @example Fake every operation in a spec, straight from the document
|
|
22
|
+
* ```typescript
|
|
23
|
+
* import { createOpenApiMock } from '@equinor/fusion-openapi-mock';
|
|
24
|
+
* import openapi from './openapi.json' with { type: 'json' };
|
|
25
|
+
*
|
|
26
|
+
* configurator.http.addMiddleware(createOpenApiMockMiddleware(createOpenApiMock(openapi)));
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function createOpenApiMockMiddleware(openApiMock: OpenApiMockLike): HttpMiddleware {
|
|
30
|
+
return async (uri, init, next) => {
|
|
31
|
+
const response = await resolveOpenApiMockResponse(
|
|
32
|
+
openApiMock,
|
|
33
|
+
init.method ?? 'GET',
|
|
34
|
+
new URL(uri),
|
|
35
|
+
);
|
|
36
|
+
return response ?? next(uri, init);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default createOpenApiMockMiddleware;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { HttpMiddleware } from '../lib/operators/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A route match handed to a {@link MockRouteHandler} once its pattern has matched a request.
|
|
5
|
+
*/
|
|
6
|
+
export interface MockRouteMatch {
|
|
7
|
+
/** Path parameters extracted from named segments in the route pattern, e.g. `:id` -> `params.id`. */
|
|
8
|
+
params: Record<string, string>;
|
|
9
|
+
/** The fully resolved request URL, for reading query parameters via `url.searchParams`. */
|
|
10
|
+
url: URL;
|
|
11
|
+
/** The request as a Fetch-standard `Request`, for reading headers or a JSON/text body. */
|
|
12
|
+
request: Request;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Builds the `Response` for one matched route. */
|
|
16
|
+
export type MockRouteHandler = (match: MockRouteMatch) => Response | Promise<Response>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Registers route handlers for {@link createRouterMiddleware}, in the style of a minimal
|
|
20
|
+
* Express-like router — path templates (`:id`) and per-method registration, without pulling
|
|
21
|
+
* in a real routing library.
|
|
22
|
+
*/
|
|
23
|
+
export interface IMockRouterBuilder {
|
|
24
|
+
/** Registers `handler` for `GET` requests matching `path`. @see {@link IMockRouterBuilder.on} */
|
|
25
|
+
get(path: string, handler: MockRouteHandler): IMockRouterBuilder;
|
|
26
|
+
/** Registers `handler` for `POST` requests matching `path`. @see {@link IMockRouterBuilder.on} */
|
|
27
|
+
post(path: string, handler: MockRouteHandler): IMockRouterBuilder;
|
|
28
|
+
/** Registers `handler` for `PUT` requests matching `path`. @see {@link IMockRouterBuilder.on} */
|
|
29
|
+
put(path: string, handler: MockRouteHandler): IMockRouterBuilder;
|
|
30
|
+
/** Registers `handler` for `PATCH` requests matching `path`. @see {@link IMockRouterBuilder.on} */
|
|
31
|
+
patch(path: string, handler: MockRouteHandler): IMockRouterBuilder;
|
|
32
|
+
/** Registers `handler` for `DELETE` requests matching `path`. @see {@link IMockRouterBuilder.on} */
|
|
33
|
+
delete(path: string, handler: MockRouteHandler): IMockRouterBuilder;
|
|
34
|
+
/**
|
|
35
|
+
* Registers `handler` for a method and path template.
|
|
36
|
+
* @param method - The HTTP method to match, case-insensitively, or `undefined` to match any method.
|
|
37
|
+
* @param path - A path template relative to the router's base URI. A segment starting with
|
|
38
|
+
* `:` (e.g. `/contexts/:id`) captures that segment into `params`; every other segment must
|
|
39
|
+
* match literally. A trailing slash is always optional.
|
|
40
|
+
* @param handler - Builds the `Response` for a matching request.
|
|
41
|
+
*/
|
|
42
|
+
on(method: string | undefined, path: string, handler: MockRouteHandler): IMockRouterBuilder;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RegisteredRoute {
|
|
46
|
+
method: string | undefined;
|
|
47
|
+
regexp: RegExp;
|
|
48
|
+
keys: string[];
|
|
49
|
+
handler: MockRouteHandler;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compiles a path template into a matching `RegExp` plus the ordered list of named parameters
|
|
54
|
+
* it captures.
|
|
55
|
+
*
|
|
56
|
+
* @param path - A path template such as `/contexts/:id`.
|
|
57
|
+
* @returns The compiled pattern and the parameter names it captures, in segment order.
|
|
58
|
+
*/
|
|
59
|
+
function compilePath(path: string): { regexp: RegExp; keys: string[] } {
|
|
60
|
+
const keys: string[] = [];
|
|
61
|
+
const pattern = path
|
|
62
|
+
.split('/')
|
|
63
|
+
// each segment either captures a path parameter or must match literally
|
|
64
|
+
.map((segment) => {
|
|
65
|
+
// only `:name` segments become capture groups; everything else must match literally
|
|
66
|
+
if (!segment.startsWith(':')) {
|
|
67
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
68
|
+
}
|
|
69
|
+
keys.push(segment.slice(1));
|
|
70
|
+
return '([^/]+)';
|
|
71
|
+
})
|
|
72
|
+
.join('/');
|
|
73
|
+
return { regexp: new RegExp(`^${pattern}/?$`), keys };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Builds an {@link HttpMiddleware} that answers requests to one base URI with hand-registered
|
|
78
|
+
* route handlers, matched by method and a path template (`:id`-style segments) — a lightweight
|
|
79
|
+
* router for tests with more than a couple of routes to fake, without hand-rolling `RegExp`
|
|
80
|
+
* matching against `uri` in every middleware.
|
|
81
|
+
*
|
|
82
|
+
* @remarks
|
|
83
|
+
* A request outside `baseUri`, or matching no registered route, falls through to `next`, so
|
|
84
|
+
* this composes with other middleware — including the real network call, or another router
|
|
85
|
+
* for a different base URI — registered around it. Routes are tried in registration order;
|
|
86
|
+
* the first match wins.
|
|
87
|
+
*
|
|
88
|
+
* Deliberately not an MSW-compatible API — this stays inside `addMiddleware`'s own request
|
|
89
|
+
* pipeline rather than intercepting the network boundary, so it has none of MSW's response
|
|
90
|
+
* transformers, `onUnhandledRequest` diagnostics, or wildcard patterns.
|
|
91
|
+
*
|
|
92
|
+
* @param baseUri - The base URI this router answers for, e.g. `https://api.example.com`.
|
|
93
|
+
* @param build - Registers routes on the given {@link IMockRouterBuilder}.
|
|
94
|
+
* @returns A middleware for {@link IHttpClientConfigurator.addMiddleware}.
|
|
95
|
+
*
|
|
96
|
+
* @example Fake a handful of routes under one base URI
|
|
97
|
+
* ```typescript
|
|
98
|
+
* configurator.http.addMiddleware(
|
|
99
|
+
* createRouterMiddleware('https://context.example.com', (router) => {
|
|
100
|
+
* router.get('/contexts/:id/relations', () => Response.json([{ id: 'ctx-3' }]));
|
|
101
|
+
* router.get('/contexts', () => Response.json([{ id: 'ctx-2' }]));
|
|
102
|
+
* router.get('/contexts/:id', ({ params }) => Response.json({ id: params.id }));
|
|
103
|
+
* }),
|
|
104
|
+
* );
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export function createRouterMiddleware(
|
|
108
|
+
baseUri: string,
|
|
109
|
+
build: (router: IMockRouterBuilder) => void,
|
|
110
|
+
): HttpMiddleware {
|
|
111
|
+
const routes: RegisteredRoute[] = [];
|
|
112
|
+
const register = (
|
|
113
|
+
method: string | undefined,
|
|
114
|
+
path: string,
|
|
115
|
+
handler: MockRouteHandler,
|
|
116
|
+
): IMockRouterBuilder => {
|
|
117
|
+
const { regexp, keys } = compilePath(path);
|
|
118
|
+
routes.push({ method: method?.toUpperCase(), regexp, keys, handler });
|
|
119
|
+
return router;
|
|
120
|
+
};
|
|
121
|
+
const router: IMockRouterBuilder = {
|
|
122
|
+
get: (path, handler) => register('GET', path, handler),
|
|
123
|
+
post: (path, handler) => register('POST', path, handler),
|
|
124
|
+
put: (path, handler) => register('PUT', path, handler),
|
|
125
|
+
patch: (path, handler) => register('PATCH', path, handler),
|
|
126
|
+
delete: (path, handler) => register('DELETE', path, handler),
|
|
127
|
+
on: register,
|
|
128
|
+
};
|
|
129
|
+
build(router);
|
|
130
|
+
|
|
131
|
+
const base = new URL(baseUri);
|
|
132
|
+
const basePath = base.pathname.replace(/\/$/, '');
|
|
133
|
+
|
|
134
|
+
return async (uri, init, next) => {
|
|
135
|
+
const url = new URL(uri);
|
|
136
|
+
// requests to a different origin, or outside this router's base path, are none of its concern
|
|
137
|
+
if (url.origin !== base.origin || !url.pathname.startsWith(basePath)) return next(uri, init);
|
|
138
|
+
|
|
139
|
+
const pathname = url.pathname.slice(basePath.length) || '/';
|
|
140
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
141
|
+
|
|
142
|
+
// first registered route whose method and pattern both match wins
|
|
143
|
+
for (const route of routes) {
|
|
144
|
+
// a route registered for a specific method never answers a request for another method
|
|
145
|
+
if (route.method && route.method !== method) continue;
|
|
146
|
+
const match = route.regexp.exec(pathname);
|
|
147
|
+
// pattern didn't match this pathname at all — try the next registered route
|
|
148
|
+
if (!match) continue;
|
|
149
|
+
// capture groups are positional, in the same order `compilePath` recorded their names
|
|
150
|
+
const params = Object.fromEntries(route.keys.map((key, i) => [key, match[i + 1]]));
|
|
151
|
+
return route.handler({ params, url, request: new Request(uri, init) });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return next(uri, init);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default createRouterMiddleware;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing utilities for the HTTP module.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Imported from `@equinor/fusion-framework-module-http/mock`, so testing
|
|
6
|
+
* utilities ship and version with the implementation they stand in for.
|
|
7
|
+
*
|
|
8
|
+
* There is no separate mock client or configurator here — register a
|
|
9
|
+
* short-circuiting `HttpMiddleware` through `configurator.http.addMiddleware(...)`
|
|
10
|
+
* on the real `HttpClientConfigurator` instead, so app config never has to
|
|
11
|
+
* branch on whether it's under test. See {@link createOpenApiMockMiddleware}
|
|
12
|
+
* for faking a whole OpenAPI document's operations that way.
|
|
13
|
+
*
|
|
14
|
+
* This entry point has no dependency on any test runner.
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { createOpenApiMockMiddleware } from './create-open-api-mock-middleware';
|
|
20
|
+
export type { OpenApiMockLike } from './resolve-open-api-mock-response';
|
|
21
|
+
export {
|
|
22
|
+
createRouterMiddleware,
|
|
23
|
+
type MockRouteHandler,
|
|
24
|
+
type MockRouteMatch,
|
|
25
|
+
type IMockRouterBuilder,
|
|
26
|
+
} from './create-router-middleware';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subset of `OpenApiMock` (from `@equinor/fusion-openapi-mock`'s
|
|
3
|
+
* `createOpenApiMock`) the adapters in this file need — duck-typed so this
|
|
4
|
+
* package has no dependency on that one; anything shaped like this works.
|
|
5
|
+
*/
|
|
6
|
+
export interface OpenApiMockLike {
|
|
7
|
+
resolve(request: {
|
|
8
|
+
method: string;
|
|
9
|
+
path: string;
|
|
10
|
+
query?: Record<string, string>;
|
|
11
|
+
}): Promise<{ status: number; mock: unknown } | undefined>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves a method and URL against an `OpenApiMock`, building a `Response`
|
|
16
|
+
* from whatever it matches — shared by every adapter targeting a different
|
|
17
|
+
* middleware shape, so the method/path/query mapping and status/body wiring
|
|
18
|
+
* lives in one place.
|
|
19
|
+
*
|
|
20
|
+
* @param openApiMock - The mock to resolve against.
|
|
21
|
+
* @param method - The request's HTTP method.
|
|
22
|
+
* @param url - The request's fully resolved URL.
|
|
23
|
+
* @returns The faked `Response`, or `undefined` when no operation matches.
|
|
24
|
+
*/
|
|
25
|
+
export async function resolveOpenApiMockResponse(
|
|
26
|
+
openApiMock: OpenApiMockLike,
|
|
27
|
+
method: string,
|
|
28
|
+
url: URL,
|
|
29
|
+
): Promise<Response | undefined> {
|
|
30
|
+
const result = await openApiMock.resolve({
|
|
31
|
+
method,
|
|
32
|
+
path: url.pathname,
|
|
33
|
+
query: Object.fromEntries(url.searchParams),
|
|
34
|
+
});
|
|
35
|
+
return result && Response.json(result.mock, { status: result.status });
|
|
36
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
IHttpClientConfigurator,
|
|
6
6
|
} from './configurator';
|
|
7
7
|
|
|
8
|
-
import type { IHttpRequestHandler } from './lib/operators';
|
|
8
|
+
import type { IHttpMiddlewareHandler, IHttpRequestHandler } from './lib/operators';
|
|
9
9
|
import type { IHttpClient } from './lib/client';
|
|
10
10
|
import { BaseModuleProvider } from '@equinor/fusion-framework-module/provider';
|
|
11
11
|
import { version } from './version';
|
|
@@ -24,6 +24,12 @@ export interface IHttpClientProvider<TClient extends IHttpClient = IHttpClient>
|
|
|
24
24
|
*/
|
|
25
25
|
readonly defaultHttpRequestHandler: IHttpRequestHandler<HttpClientRequestInitType<TClient>>;
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The default middleware chain used by the HttpClientProvider, wrapping the network call
|
|
29
|
+
* for every client it creates unless a client overrides it with its own `middlewareHandler`.
|
|
30
|
+
*/
|
|
31
|
+
readonly defaultHttpMiddlewareHandler: IHttpMiddlewareHandler;
|
|
32
|
+
|
|
27
33
|
/**
|
|
28
34
|
* Checks if a client is configured with the given key.
|
|
29
35
|
* @param key - The key of the client to check.
|
|
@@ -98,6 +104,14 @@ export class HttpClientProvider<TClient extends IHttpClient = IHttpClient>
|
|
|
98
104
|
return this.config.defaultHttpRequestHandler;
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Gets the default middleware chain for the HTTP client provider.
|
|
109
|
+
* @returns The default middleware chain.
|
|
110
|
+
*/
|
|
111
|
+
get defaultHttpMiddlewareHandler(): IHttpMiddlewareHandler {
|
|
112
|
+
return this.config.defaultHttpMiddlewareHandler;
|
|
113
|
+
}
|
|
114
|
+
|
|
101
115
|
/**
|
|
102
116
|
* Creates a new `HttpClientProvider`.
|
|
103
117
|
* @param config - The configurator providing client definitions and defaults.
|
|
@@ -143,8 +157,9 @@ export class HttpClientProvider<TClient extends IHttpClient = IHttpClient>
|
|
|
143
157
|
ctor = this.config.defaultHttpClientCtor,
|
|
144
158
|
requestHandler = this.defaultHttpRequestHandler,
|
|
145
159
|
responseHandler,
|
|
160
|
+
middlewareHandler = this.defaultHttpMiddlewareHandler,
|
|
146
161
|
} = config as HttpClientOptions<TClient>;
|
|
147
|
-
const options = { requestHandler, responseHandler };
|
|
162
|
+
const options = { requestHandler, responseHandler, middlewareHandler };
|
|
148
163
|
const instance = new ctor(baseUri || '', options) as TClient;
|
|
149
164
|
// attach the resolved default scopes onto the instance without overwriting other own properties
|
|
150
165
|
Object.assign(instance, { defaultScopes });
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by genversion.
|
|
2
|
-
export const version = '8.0.
|
|
2
|
+
export const version = '8.1.0-next.0';
|
package/tests/HttpClient.test.ts
CHANGED
|
@@ -111,6 +111,52 @@ describe('HttpClient', () => {
|
|
|
111
111
|
expect.stringContaining('missing the http:// or https:// protocol'),
|
|
112
112
|
);
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
it('runs middleware registered through the configurator around every created client', async () => {
|
|
116
|
+
const config = new HttpClientConfigurator(HttpClient);
|
|
117
|
+
config.configureClient('foo', 'http://localhost:3000');
|
|
118
|
+
config.addMiddleware(() => Response.json('from-middleware'));
|
|
119
|
+
|
|
120
|
+
const client = new HttpClientProvider(config).createClient('foo');
|
|
121
|
+
|
|
122
|
+
await expect(client.json('/api')).resolves.toBe('from-middleware');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('lets middleware call next through to the real network call', async () => {
|
|
126
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json('from-network'));
|
|
127
|
+
|
|
128
|
+
const config = new HttpClientConfigurator(HttpClient);
|
|
129
|
+
config.configureClient('foo', 'http://localhost:3000');
|
|
130
|
+
config.addMiddleware((uri, init, next) => next(uri, init));
|
|
131
|
+
|
|
132
|
+
const client = new HttpClientProvider(config).createClient('foo');
|
|
133
|
+
|
|
134
|
+
await expect(client.json('/api')).resolves.toBe('from-network');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('aborts the underlying fetch on abort(), even behind a pass-through middleware', async () => {
|
|
138
|
+
// `next(...)` resolves through a Promise (`HttpMiddlewareHandler`'s `toPromise`), so this
|
|
139
|
+
// asserts the fix doesn't rely on RxJS unsubscription reaching `_performFetch` through it.
|
|
140
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(
|
|
141
|
+
(_input, init) =>
|
|
142
|
+
new Promise((_resolve, reject) => {
|
|
143
|
+
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason));
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const config = new HttpClientConfigurator(HttpClient);
|
|
148
|
+
config.configureClient('foo', 'http://localhost:3000');
|
|
149
|
+
config.addMiddleware((uri, init, next) => next(uri, init));
|
|
150
|
+
|
|
151
|
+
const client = new HttpClientProvider(config).createClient('foo');
|
|
152
|
+
const request = client.json('/api');
|
|
153
|
+
// let the request reach `_performFetch` (and register the abort listener) before aborting
|
|
154
|
+
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
|
|
155
|
+
client.abort();
|
|
156
|
+
|
|
157
|
+
await expect(request).rejects.toBeDefined();
|
|
158
|
+
expect(fetchSpy.mock.calls[0][1]?.signal?.aborted).toBe(true);
|
|
159
|
+
});
|
|
114
160
|
});
|
|
115
161
|
|
|
116
162
|
describe('HttpClientMsal', () => {
|