@equinor/fusion-framework-module-http 7.0.8 → 8.0.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 (41) hide show
  1. package/CHANGELOG.md +34 -27
  2. package/README.md +141 -925
  3. package/dist/esm/configurator.js +5 -5
  4. package/dist/esm/configurator.js.map +1 -1
  5. package/dist/esm/lib/client/client-msal.js +3 -2
  6. package/dist/esm/lib/client/client-msal.js.map +1 -1
  7. package/dist/esm/lib/client/client.js +8 -7
  8. package/dist/esm/lib/client/client.js.map +1 -1
  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/lib/selectors/index.js +1 -0
  12. package/dist/esm/lib/selectors/index.js.map +1 -1
  13. package/dist/esm/module.js +18 -9
  14. package/dist/esm/module.js.map +1 -1
  15. package/dist/esm/provider.js +38 -27
  16. package/dist/esm/provider.js.map +1 -1
  17. package/dist/esm/version.js +1 -1
  18. package/dist/tsconfig.tsbuildinfo +1 -1
  19. package/dist/types/configurator.d.ts +49 -26
  20. package/dist/types/lib/client/client.d.ts +7 -7
  21. package/dist/types/lib/operators/index.d.ts +1 -0
  22. package/dist/types/lib/selectors/index.d.ts +2 -0
  23. package/dist/types/module.d.ts +18 -9
  24. package/dist/types/provider.d.ts +24 -19
  25. package/dist/types/version.d.ts +1 -1
  26. package/docs/client-configuration.md +175 -0
  27. package/docs/observable-patterns.md +103 -0
  28. package/docs/selectors-and-handlers.md +112 -0
  29. package/docs/server-sent-events.md +125 -0
  30. package/package.json +6 -6
  31. package/src/configurator.ts +54 -26
  32. package/src/lib/client/client-msal.ts +3 -2
  33. package/src/lib/client/client.ts +8 -9
  34. package/src/lib/operators/index.ts +1 -0
  35. package/src/lib/selectors/index.ts +7 -0
  36. package/src/module.ts +18 -9
  37. package/src/provider.ts +56 -34
  38. package/src/version.ts +1 -1
  39. package/tests/HttpClient.test.ts +90 -11
  40. package/tests/operators.test.ts +24 -0
  41. package/tests/sse.selector.test.ts +6 -7
package/src/provider.ts CHANGED
@@ -11,15 +11,19 @@ import { BaseModuleProvider } from '@equinor/fusion-framework-module/provider';
11
11
  import { version } from './version';
12
12
 
13
13
  /**
14
- * Exception thrown when a client cannot be found.
14
+ * Thrown when `createClient(name)` is called with an unknown client key.
15
15
  *
16
- * This error is typically used to indicate that a requested client instance
17
- * does not exist or cannot be located within the current context.
18
- *
19
- * @extends {Error}
16
+ * This is only used when the provided string is neither a registered client name
17
+ * nor an absolute `http:` or `https:` URL.
20
18
  */
21
19
  export class ClientNotFoundException extends Error {}
22
20
 
21
+ /**
22
+ * Creates fresh HTTP client instances from named or ad-hoc configuration.
23
+ *
24
+ * A provider can create clients from registered names, inline `HttpClientOptions`,
25
+ * or absolute URLs treated as ad-hoc `baseUri` values.
26
+ */
23
27
  export interface IHttpClientProvider<TClient extends IHttpClient = IHttpClient> {
24
28
  /**
25
29
  * The default HTTP request handler used by the HttpClientProvider.
@@ -43,35 +47,48 @@ export interface IHttpClientProvider<TClient extends IHttpClient = IHttpClient>
43
47
  createClient(key: HttpClientOptions<TClient>): TClient;
44
48
 
45
49
  /**
46
- * Class cast creation of custom client
50
+ * Creates a client instance and casts it to a custom HTTP client type.
51
+ *
52
+ * This is most useful when the named client configuration uses a custom `ctor`
53
+ * that extends `HttpClient` with domain-specific methods.
47
54
  * @example
48
55
  * ```ts
49
- * config.http.configureClient('foobar', (client) => {
50
- * client.ctor = MyClient;
51
- * client.uri = 'https://foobar.com';
56
+ * config.http.configureClient('foobar', {
57
+ * ctor: MyClient,
58
+ * baseUri: 'https://foobar.com',
52
59
  * });
60
+ * ```
53
61
  */
54
62
  createCustomClient<T extends HttpClient>(key: string): T;
55
63
  }
56
64
 
65
+ /** URL protocols accepted as valid ad-hoc base URIs. */
66
+ const SUPPORTED_PROTOCOLS = ['http:', 'https:', 'ws:', 'wss:'] as const;
67
+
57
68
  /**
58
- * Checks if a given string is a valid URL.
59
- * @param url - The string to check for a valid URL.
60
- * @returns `true` if the input string is a valid URL, `false` otherwise.
69
+ * Checks if a given string is a valid absolute URL with a supported protocol.
70
+ * @param url - The string to check.
71
+ * @returns `true` when the string uses one of {@link SUPPORTED_PROTOCOLS}.
61
72
  */
62
- const isURL = (url: string) => {
63
- const pattern = new RegExp(
64
- '^(https?:\\/\\/)?' + // protocol
65
- '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.?)+[a-z]{2,}|' + // domain name
66
- '((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
67
- '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
68
- '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
69
- '(\\#[-a-z\\d_]*)?$',
70
- 'i',
71
- ); // fragment locator
72
- return pattern.test(url);
73
+ const isURL = (url: string): boolean => {
74
+ try {
75
+ const parsed = new URL(url);
76
+ return (SUPPORTED_PROTOCOLS as readonly string[]).includes(parsed.protocol);
77
+ } catch {
78
+ return false;
79
+ }
73
80
  };
74
81
 
82
+ /**
83
+ * Heuristic check for strings that look like a bare hostname or URL without a
84
+ * protocol prefix (e.g. `api.example.com` or `api.example.com/v1`).
85
+ *
86
+ * Used to emit a deprecation warning when callers rely on the old (pre-patch)
87
+ * behaviour that accepted protocol-less URLs.
88
+ */
89
+ const looksLikeURL = (value: string): boolean =>
90
+ !value.includes(' ') && /^[a-z\d]([a-z\d-]*\.)+[a-z]{2,}/i.test(value);
91
+
75
92
  /**
76
93
  * The `HttpClientProvider` class is responsible for managing HTTP client instances and their configuration.
77
94
  * It provides methods to check if a client is configured, create new client instances, and create custom client instances.
@@ -105,7 +122,7 @@ export class HttpClientProvider<TClient extends IHttpClient = IHttpClient>
105
122
  }
106
123
 
107
124
  /**
108
- * Creates a new HTTP client instance with the specified configuration.
125
+ * Creates a fresh HTTP client instance from a named or ad-hoc configuration.
109
126
  *
110
127
  * @param keyOrConfig - The key or configuration object for the HTTP client.
111
128
  * @returns The created HTTP client instance.
@@ -115,12 +132,8 @@ export class HttpClientProvider<TClient extends IHttpClient = IHttpClient>
115
132
  * If a string is provided, it is treated as the key for a pre-configured client in the `HttpClientProvider`.
116
133
  * If an `HttpClientOptions` object is provided, it is used as the configuration for the new client instance.
117
134
  *
118
- * The method sets up the HTTP client with the following options:
119
- * - `baseUri`: The base URI for the HTTP client.
120
- * - `defaultScopes`: The default scopes to be used for authentication.
121
- * - `onCreate`: An optional callback function that is called when the client instance is created.
122
- * - `ctor`: The constructor function for the HTTP client, defaulting to the configured `defaultHttpClientCtor`.
123
- * - `requestHandler`: The HTTP request handler to be used by the client, defaulting to the `defaultHttpRequestHandler`.
135
+ * The method applies `baseUri`, `defaultScopes`, `requestHandler`, `responseHandler`,
136
+ * a custom `ctor` when configured, and finally runs `onCreate` for the newly created instance.
124
137
  *
125
138
  * The created HTTP client instance is returned.
126
139
  */
@@ -132,8 +145,9 @@ export class HttpClientProvider<TClient extends IHttpClient = IHttpClient>
132
145
  onCreate,
133
146
  ctor = this.config.defaultHttpClientCtor,
134
147
  requestHandler = this.defaultHttpRequestHandler,
148
+ responseHandler,
135
149
  } = config as HttpClientOptions<TClient>;
136
- const options = { requestHandler };
150
+ const options = { requestHandler, responseHandler };
137
151
  const instance = new ctor(baseUri || '', options) as TClient;
138
152
  Object.assign(instance, { defaultScopes });
139
153
  onCreate?.(instance as TClient);
@@ -141,7 +155,7 @@ export class HttpClientProvider<TClient extends IHttpClient = IHttpClient>
141
155
  }
142
156
 
143
157
  /**
144
- * Creates a new HTTP client instance with the specified configuration.
158
+ * Creates a client instance and returns it as the requested custom client type.
145
159
  *
146
160
  * @param key - The key of the pre-configured HTTP client to create.
147
161
  * @returns The created HTTP client instance, cast to the specified type `T`.
@@ -159,8 +173,9 @@ export class HttpClientProvider<TClient extends IHttpClient = IHttpClient>
159
173
  /**
160
174
  * Resolves the configuration for an HTTP client based on the provided `keyOrConfig` parameter.
161
175
  *
162
- * If a string is provided, it is treated as the key for a pre-configured client in the `HttpClientProvider`.
163
- * If an `HttpClientOptions` object is provided, it is used as the configuration for the new client instance.
176
+ * If a string is provided, it is treated as either a pre-configured client key or,
177
+ * when it is an absolute `http:` or `https:` URL, as an ad-hoc `baseUri`.
178
+ * If an `HttpClientOptions` object is provided, it is used directly as the configuration for the new client instance.
164
179
  *
165
180
  * @param keyOrConfig - The key or configuration object for the HTTP client.
166
181
  * @returns The resolved HTTP client configuration.
@@ -172,6 +187,13 @@ export class HttpClientProvider<TClient extends IHttpClient = IHttpClient>
172
187
  const config = this.config.clients[keyOrConfig];
173
188
  if (!config && isURL(keyOrConfig)) {
174
189
  return { baseUri: keyOrConfig };
190
+ } else if (!config && looksLikeURL(keyOrConfig)) {
191
+ console.warn(
192
+ `[HttpClientProvider] "${keyOrConfig}" looks like a URL but is missing the http:// or https:// protocol. ` +
193
+ `Treating it as "https://${keyOrConfig}". ` +
194
+ `Pass a fully-qualified URL to silence this warning.`,
195
+ );
196
+ return { baseUri: `https://${keyOrConfig}` };
175
197
  } else if (!config) {
176
198
  throw new ClientNotFoundException(`No registered http client for key [${keyOrConfig}]`);
177
199
  }
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '7.0.8';
2
+ export const version = '8.0.0';
@@ -1,9 +1,10 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { lastValueFrom } from 'rxjs';
2
3
 
4
+ import { ClientNotFoundException, HttpClientProvider } from '../src';
3
5
  import { HttpClientConfigurator } from '../src/configurator';
4
- import { HttpClient } from '../src/lib';
5
- import { HttpClientProvider } from '../src';
6
- import { lastValueFrom } from 'rxjs';
6
+ import { HttpClient, HttpClientMsal } from '../src/lib';
7
+ import { HttpResponseHandler } from '../src/lib/operators';
7
8
 
8
9
  let provider: HttpClientProvider;
9
10
 
@@ -14,35 +15,113 @@ describe('HttpClient', () => {
14
15
  provider = new HttpClientProvider(config);
15
16
  });
16
17
 
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+
17
22
  it('should create instance', () => {
18
23
  const client = provider.createClient('foo');
19
24
  expect(client).toBeDefined();
20
25
  expect(client.uri).toBe('http://localhost:3000');
21
26
  });
22
27
 
23
- it('should allow providing headers in request', async () => {});
28
+ it('should allow providing headers in request', async () => {
29
+ const client = provider.createClient('foo');
30
+ client.requestHandler.setHeader('x-foo', 'bar');
31
+
32
+ const request = await lastValueFrom(
33
+ client.requestHandler.process({
34
+ path: '/api',
35
+ uri: client.uri,
36
+ headers: { 'x-bar': 'baz' },
37
+ }),
38
+ );
39
+
40
+ const headers = request.headers as Headers;
41
+ expect(headers.get('x-foo')).toBe('bar');
42
+ expect(headers.get('x-bar')).toBe('baz');
43
+ });
24
44
 
25
45
  it('should not modify configured headers', async () => {
26
- // create a new client from configuration
27
46
  const fooClient = provider.createClient('foo');
28
47
  fooClient.requestHandler.setHeader('x-foo', 'bar');
29
48
 
30
- // generate a RequestInit object
31
49
  const fooRequest = await lastValueFrom(
32
50
  fooClient.requestHandler.process({ path: '/api', uri: fooClient.uri }),
33
51
  );
34
52
 
35
53
  expect((fooRequest.headers as Headers)?.get('x-foo')).toBe('bar');
36
54
 
37
- // create a new client from the same configuration
38
55
  const barClient = provider.createClient('foo');
39
-
40
- // generate a RequestInit object
41
56
  const barRequest = await lastValueFrom(
42
57
  barClient.requestHandler.process({ path: '/api', uri: barClient.uri }),
43
58
  );
44
59
 
45
- // expect the request header to not been modified
46
60
  expect((barRequest.headers as Headers)?.get('x-foo')).toBeUndefined();
47
61
  });
62
+
63
+ it('should execute configured response handlers from client options', async () => {
64
+ const responseSpy = vi.fn();
65
+ const config = new HttpClientConfigurator(HttpClient);
66
+
67
+ config.configureClient('bar', {
68
+ baseUri: 'http://localhost:3000',
69
+ responseHandler: new HttpResponseHandler({
70
+ 'response-spy': responseSpy,
71
+ }),
72
+ });
73
+
74
+ const localProvider = new HttpClientProvider(config);
75
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
76
+
77
+ await localProvider.createClient('bar').fetch('/api');
78
+
79
+ expect(responseSpy).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it('should emit the original path on the request stream', async () => {
83
+ const requestSpy = vi.fn();
84
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
85
+
86
+ const client = provider.createClient('foo');
87
+ const subscription = client.request$.subscribe(requestSpy);
88
+
89
+ await client.fetch('/api');
90
+ subscription.unsubscribe();
91
+
92
+ expect(requestSpy).toHaveBeenCalledWith(
93
+ expect.objectContaining({
94
+ path: '/api',
95
+ uri: 'http://localhost:3000/api',
96
+ }),
97
+ );
98
+ });
99
+
100
+ it('should throw when a configured client cannot be found', () => {
101
+ expect(() => provider.createClient('missing-client')).toThrowError(
102
+ new ClientNotFoundException('No registered http client for key [missing-client]'),
103
+ );
104
+ });
105
+
106
+ it('should treat a bare hostname as https:// URL with a warning', () => {
107
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
108
+ const client = provider.createClient('api.example.com');
109
+ expect(client.uri).toBe('https://api.example.com');
110
+ expect(warnSpy).toHaveBeenCalledWith(
111
+ expect.stringContaining('missing the http:// or https:// protocol'),
112
+ );
113
+ });
114
+ });
115
+
116
+ describe('HttpClientMsal', () => {
117
+ it('should not mutate the provided request init object', () => {
118
+ const client = new HttpClientMsal('http://localhost:3000');
119
+ client.defaultScopes = ['scope.default'];
120
+
121
+ const init = { scopes: ['scope.request'] };
122
+
123
+ client.fetch$('/api', init);
124
+
125
+ expect(init).toStrictEqual({ scopes: ['scope.request'] });
126
+ });
48
127
  });
@@ -1,9 +1,12 @@
1
1
  /* eslint-disable @typescript-eslint/ban-ts-comment */
2
2
  import { describe, it, expect, vi } from 'vitest';
3
+ import { lastValueFrom, of } from 'rxjs';
4
+ import { toArray } from 'rxjs/operators';
3
5
  import {
4
6
  capitalizeRequestMethodOperator,
5
7
  type ProcessOperator,
6
8
  requestValidationOperator,
9
+ sseMap,
7
10
  } from '../src/lib/operators';
8
11
  import type { FetchRequest } from '../src/lib';
9
12
 
@@ -107,3 +110,24 @@ describe('requestValidationOperator', () => {
107
110
  await expect(result).rejects.toThrowError('RFC 2615');
108
111
  });
109
112
  });
113
+
114
+ describe('sseMap', () => {
115
+ it('should map SSE responses through the operators index export', async () => {
116
+ const response = new Response(
117
+ new ReadableStream({
118
+ start(controller) {
119
+ controller.enqueue(new TextEncoder().encode('data: {"key": "value"}\n\n'));
120
+ controller.close();
121
+ },
122
+ }),
123
+ {
124
+ headers: { 'Content-Type': 'text/event-stream' },
125
+ status: 200,
126
+ },
127
+ );
128
+
129
+ const events = await lastValueFrom(of(response).pipe(sseMap<{ key: string }>(), toArray()));
130
+
131
+ expect(events).toEqual([{ data: { key: 'value' } }]);
132
+ });
133
+ });
@@ -1,9 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { ServerSentEventResponseError } from '../src/errors.js';
3
- import { createSseSelector } from '../src/lib/selectors/sse-selector.js'; // Adjust the import path as needed
3
+ import { createSseSelector, type ServerSentEvent } from '../src/lib/selectors';
4
4
  import { of, lastValueFrom } from 'rxjs';
5
5
  import { concatMap, scan } from 'rxjs/operators';
6
- import { set } from 'zod';
7
6
 
8
7
  // Helper to create a mock Response
9
8
  function createMockResponse(
@@ -34,7 +33,7 @@ describe('createSseSelector', () => {
34
33
  const events = await lastValueFrom(
35
34
  of(response).pipe(
36
35
  concatMap(selector),
37
- scan((acc, event) => [...acc, event], [] as any[]),
36
+ scan((acc, event) => [...acc, event], [] as ServerSentEvent[]),
38
37
  ),
39
38
  );
40
39
  expect(events).toEqual([{ data: { key: 'value' } }]);
@@ -79,7 +78,7 @@ describe('createSseSelector', () => {
79
78
  const events = await lastValueFrom(
80
79
  of(response).pipe(
81
80
  concatMap(selector),
82
- scan((acc, event) => [...acc, event], [] as any[]),
81
+ scan((acc, event) => [...acc, event], [] as ServerSentEvent[]),
83
82
  ),
84
83
  );
85
84
 
@@ -98,7 +97,7 @@ describe('createSseSelector', () => {
98
97
  const events = await lastValueFrom(
99
98
  of(response).pipe(
100
99
  concatMap(selector),
101
- scan((acc, event) => [...acc, event], [] as any[]),
100
+ scan((acc, event) => [...acc, event], [] as ServerSentEvent[]),
102
101
  ),
103
102
  );
104
103
 
@@ -117,7 +116,7 @@ describe('createSseSelector', () => {
117
116
  const events = await lastValueFrom(
118
117
  of(response).pipe(
119
118
  concatMap(selector),
120
- scan((acc, event) => [...acc, event], [] as any[]),
119
+ scan((acc, event) => [...acc, event], [] as ServerSentEvent[]),
121
120
  ),
122
121
  );
123
122
 
@@ -136,7 +135,7 @@ describe('createSseSelector', () => {
136
135
  const eventsPromise = lastValueFrom(
137
136
  of(response).pipe(
138
137
  concatMap(selector),
139
- scan((acc, event) => [...acc, event], [] as any[]),
138
+ scan((acc, event) => [...acc, event], [] as ServerSentEvent[]),
140
139
  ),
141
140
  );
142
141