@equinor/fusion-framework-module-http 8.0.5 → 8.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +7 -0
  3. package/dist/esm/configurator.js +8 -1
  4. package/dist/esm/configurator.js.map +1 -1
  5. package/dist/esm/lib/client/client.js +50 -7
  6. package/dist/esm/lib/client/client.js.map +1 -1
  7. package/dist/esm/lib/operators/HttpMiddlewareHandler.js +45 -0
  8. package/dist/esm/lib/operators/HttpMiddlewareHandler.js.map +1 -0
  9. package/dist/esm/lib/operators/index.js +1 -0
  10. package/dist/esm/lib/operators/index.js.map +1 -1
  11. package/dist/esm/mock/create-open-api-mock-middleware.js +33 -0
  12. package/dist/esm/mock/create-open-api-mock-middleware.js.map +1 -0
  13. package/dist/esm/mock/create-router-middleware.js +97 -0
  14. package/dist/esm/mock/create-router-middleware.js.map +1 -0
  15. package/dist/esm/mock/index.js +20 -0
  16. package/dist/esm/mock/index.js.map +1 -0
  17. package/dist/esm/mock/resolve-open-api-mock-response.js +20 -0
  18. package/dist/esm/mock/resolve-open-api-mock-response.js.map +1 -0
  19. package/dist/esm/provider.js +9 -2
  20. package/dist/esm/provider.js.map +1 -1
  21. package/dist/esm/version.js +1 -1
  22. package/dist/tsconfig.tsbuildinfo +1 -1
  23. package/dist/types/configurator.d.ts +28 -1
  24. package/dist/types/lib/client/client.d.ts +26 -2
  25. package/dist/types/lib/operators/HttpMiddlewareHandler.d.ts +24 -0
  26. package/dist/types/lib/operators/index.d.ts +1 -0
  27. package/dist/types/lib/operators/types.d.ts +74 -1
  28. package/dist/types/mock/create-open-api-mock-middleware.d.ts +28 -0
  29. package/dist/types/mock/create-router-middleware.d.ts +73 -0
  30. package/dist/types/mock/index.d.ts +20 -0
  31. package/dist/types/mock/resolve-open-api-mock-response.d.ts +27 -0
  32. package/dist/types/provider.d.ts +11 -1
  33. package/dist/types/version.d.ts +1 -1
  34. package/docs/testing.md +78 -0
  35. package/package.json +11 -4
  36. package/src/configurator.ts +41 -1
  37. package/src/lib/client/client.ts +59 -7
  38. package/src/lib/operators/HttpMiddlewareHandler.ts +58 -0
  39. package/src/lib/operators/index.ts +1 -0
  40. package/src/lib/operators/types.ts +87 -1
  41. package/src/mock/create-open-api-mock-middleware.ts +40 -0
  42. package/src/mock/create-router-middleware.ts +158 -0
  43. package/src/mock/index.ts +26 -0
  44. package/src/mock/resolve-open-api-mock-response.ts +36 -0
  45. package/src/provider.ts +17 -2
  46. package/src/version.ts +1 -1
  47. package/tests/HttpClient.test.ts +46 -0
  48. package/tests/HttpMiddlewareHandler.test.ts +58 -0
  49. package/tests/mock/adapters.test.ts +62 -0
  50. package/tests/mock/router-middleware.test.ts +135 -0
  51. package/vitest.config.ts +1 -1
@@ -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 { IHttpRequestHandler, IHttpResponseHandler } from '../operators';
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 }) => fromFetch(uri, 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.5';
2
+ export const version = '8.1.0';
@@ -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', () => {