@schibsted/account-sdk-browser 6.0.0-alpha.1 → 6.0.0-alpha.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.
Files changed (89) hide show
  1. package/README.md +39 -64
  2. package/dist/account-sdk.d.ts +4 -0
  3. package/dist/account.d.ts +29 -0
  4. package/dist/cache.d.ts +65 -0
  5. package/dist/config.d.ts +86 -0
  6. package/dist/get-account-sdk.d.ts +3 -0
  7. package/dist/global-registry.d.ts +23 -0
  8. package/dist/globals.d.ts +15 -0
  9. package/dist/has-access-response.d.ts +2 -0
  10. package/dist/identity.d.ts +323 -0
  11. package/dist/index.d.ts +8 -0
  12. package/dist/index.js +859 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/monetization.d.ts +58 -0
  15. package/{src → dist}/object.d.ts +4 -9
  16. package/dist/popup.d.ts +9 -0
  17. package/dist/resolve-browser-window.d.ts +1 -0
  18. package/dist/rest-client.d.ts +102 -0
  19. package/dist/rest-clients.d.ts +10 -0
  20. package/dist/sdk-error.d.ts +27 -0
  21. package/dist/session-response.d.ts +30 -0
  22. package/{src/spidTalk.d.ts → dist/spid-talk.d.ts} +4 -6
  23. package/dist/types/account.d.ts +13 -0
  24. package/dist/types/common.d.ts +22 -0
  25. package/dist/types/identity.d.ts +41 -0
  26. package/dist/types/login.d.ts +120 -0
  27. package/dist/types/monetization-guards.d.ts +2 -0
  28. package/dist/types/monetization.d.ts +21 -0
  29. package/dist/types/session-guards.d.ts +36 -0
  30. package/dist/types/session.d.ts +124 -0
  31. package/dist/url.d.ts +8 -0
  32. package/dist/validate.d.ts +50 -0
  33. package/dist/version.d.ts +2 -0
  34. package/package.json +30 -22
  35. package/src/account-sdk.ts +74 -0
  36. package/src/account.ts +149 -0
  37. package/src/{cache.js → cache.ts} +53 -38
  38. package/src/{config.js → config.ts} +7 -32
  39. package/src/get-account-sdk.ts +94 -0
  40. package/src/global-registry.ts +39 -0
  41. package/src/globals.ts +12 -0
  42. package/src/has-access-response.ts +35 -0
  43. package/src/identity.ts +1102 -0
  44. package/src/index.ts +32 -0
  45. package/src/{monetization.js → monetization.ts} +65 -73
  46. package/src/{object.js → object.ts} +9 -16
  47. package/src/popup.ts +74 -0
  48. package/src/resolve-browser-window.ts +11 -0
  49. package/src/rest-client.ts +253 -0
  50. package/src/rest-clients.ts +30 -0
  51. package/src/sdk-error.ts +59 -0
  52. package/src/session-response.ts +85 -0
  53. package/src/{spidTalk.js → spid-talk.ts} +10 -12
  54. package/src/types/account.ts +27 -0
  55. package/src/types/common.ts +26 -0
  56. package/src/types/identity.ts +55 -0
  57. package/src/types/login.ts +145 -0
  58. package/src/types/monetization-guards.ts +35 -0
  59. package/src/types/monetization.ts +24 -0
  60. package/src/types/session-guards.ts +89 -0
  61. package/src/types/session.ts +147 -0
  62. package/src/{url.js → url.ts} +6 -10
  63. package/src/{validate.js → validate.ts} +27 -43
  64. package/src/{version.js → version.ts} +1 -2
  65. package/identity.d.ts +0 -1
  66. package/identity.js +0 -5
  67. package/index.d.ts +0 -4
  68. package/index.js +0 -9
  69. package/monetization.d.ts +0 -1
  70. package/monetization.js +0 -5
  71. package/payment.d.ts +0 -1
  72. package/payment.js +0 -5
  73. package/src/RESTClient.d.ts +0 -89
  74. package/src/RESTClient.js +0 -193
  75. package/src/SDKError.d.ts +0 -16
  76. package/src/SDKError.js +0 -55
  77. package/src/cache.d.ts +0 -64
  78. package/src/config.d.ts +0 -34
  79. package/src/global-registry.js +0 -20
  80. package/src/identity.d.ts +0 -679
  81. package/src/identity.js +0 -1154
  82. package/src/monetization.d.ts +0 -80
  83. package/src/payment.d.ts +0 -115
  84. package/src/payment.js +0 -211
  85. package/src/popup.d.ts +0 -10
  86. package/src/popup.js +0 -59
  87. package/src/url.d.ts +0 -10
  88. package/src/validate.d.ts +0 -64
  89. package/src/version.d.ts +0 -2
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ /* Copyright 2026 Schibsted Products & Technology AS. Licensed under the terms of the MIT license.
2
+ * See LICENSE.md in the project root.
3
+ */
4
+
5
+ export { Account } from './account.js';
6
+ export { createAccountSdk, getAccountSdk } from './account-sdk.js';
7
+ export { default as SDKError } from './sdk-error.js';
8
+
9
+ /* public available types */
10
+ export type {
11
+ AccountConfig,
12
+ AccountEventHandler,
13
+ AccountEventName,
14
+ AccountSessionEventName,
15
+ } from './types/account.js';
16
+ export type { AccountEnvironment, LogFunction } from './types/common.js';
17
+ export type {
18
+ LoginOptions,
19
+ SimplifiedLoginData,
20
+ SimplifiedLoginWidgetLoginOptions,
21
+ SimplifiedLoginWidgetOptions,
22
+ } from './types/login.js';
23
+ export type { HasAccessResult } from './types/monetization.js';
24
+ export type {
25
+ ConnectedSessionResponse,
26
+ ConnectedSessionWithUserIdResponse,
27
+ RedirectSessionResponse,
28
+ SessionFailureResponse,
29
+ SessionResponse,
30
+ SessionSuccessResponse,
31
+ SessionUserId,
32
+ } from './types/session.js';
@@ -2,94 +2,85 @@
2
2
  * See LICENSE.md in the project root.
3
3
  */
4
4
 
5
- 'use strict';
6
-
7
- import { assert, isStr, isNonEmptyString, isUrl } from './validate.js';
8
- import { urlMapper } from './url.js';
9
- import { ENDPOINTS, NAMESPACE } from './config.js';
10
- import EventEmitter from 'tiny-emitter';
11
- import RESTClient from './RESTClient.js';
5
+ import { TinyEmitter } from 'tiny-emitter';
12
6
  import Cache from './cache.js';
13
- import * as spidTalk from './spidTalk.js';
14
- import SDKError from './SDKError.js';
7
+ import { ENDPOINTS } from './config.js';
8
+ import { registerAndDispatchInGlobal } from './global-registry.js';
9
+ import { parseHasAccessResult } from './has-access-response.js';
10
+ import type { SessionUserId } from './identity.js';
11
+ import type RESTClient from './rest-client.js';
12
+ import { buildClientSdrn, createAccountRestClient } from './rest-clients.js';
13
+ import SDKError from './sdk-error.js';
14
+ import * as spidTalk from './spid-talk.js';
15
+ import type { HasAccessResult, MonetizationOptions } from './types/monetization.js';
16
+ import { urlMapper } from './url.js';
17
+ import { assert, isNonEmptyString, isStr, isUrl } from './validate.js';
15
18
  import version from './version.js';
16
- import { registerGlobal } from './global-registry.js';
17
19
 
18
20
  const globalWindow = () => window;
19
21
 
20
22
  /**
21
23
  * Provides features related to monetization
22
24
  */
23
- export class Monetization extends EventEmitter {
25
+ export class Monetization extends TinyEmitter {
26
+ cache: Cache;
27
+ clientId: string;
28
+ env: string;
29
+ redirectUri?: string;
30
+ _spid: RESTClient;
31
+ _sessionService?: RESTClient;
32
+ private pendingHasAccessRequests: Record<string, Promise<HasAccessResult>>;
33
+
24
34
  /**
25
- * @param {object} options
26
- * @param {string} options.clientId - Mandatory client id
27
- * @param {string} [options.redirectUri] - Redirect uri
28
- * @param {string} options.sessionDomain - Example: "https://id.site.com"
29
- * @param {string} [options.env=PRE] - Schibsted account environment: `PRE`, `PRO` or `PRO_NO`
30
- * @param {object} [options.window]
35
+ * @param options - Monetization configuration
31
36
  * @throws {SDKError} - If any of options are invalid
32
37
  */
33
- constructor({ clientId, redirectUri, env = 'PRE', sessionDomain, window = globalWindow() }) {
38
+ constructor({
39
+ clientId,
40
+ redirectUri,
41
+ env = 'PRE',
42
+ sessionDomain,
43
+ window = globalWindow(),
44
+ }: MonetizationOptions) {
34
45
  super();
35
46
  spidTalk.emulate(window);
36
- // validate options
37
47
  assert(isNonEmptyString(clientId), 'clientId parameter is required');
48
+ assert(isStr(env), `env parameter is invalid: ${env}`);
38
49
 
39
50
  this.cache = new Cache(() => window && window.sessionStorage);
40
51
  this.clientId = clientId;
41
52
  this.env = env;
42
53
  this.redirectUri = redirectUri;
43
54
  this.pendingHasAccessRequests = {};
44
- this._setSpidServerUrl(env);
55
+ this._spid = createAccountRestClient({
56
+ serverUrl: urlMapper(env, ENDPOINTS.SPiD),
57
+ defaultParams: { client_id: this.clientId, redirect_uri: this.redirectUri },
58
+ });
45
59
 
46
60
  if (sessionDomain) {
47
61
  assert(isUrl(sessionDomain), 'sessionDomain parameter is not a valid URL');
48
- this._setSessionServiceUrl(sessionDomain);
62
+ this._sessionService = createAccountRestClient({
63
+ serverUrl: sessionDomain,
64
+ defaultParams: {
65
+ client_sdrn: buildClientSdrn(this.env, this.clientId),
66
+ redirect_uri: this.redirectUri,
67
+ sdk_version: version,
68
+ },
69
+ });
49
70
  }
50
- registerGlobal(window, 'Monetization', this);
51
- }
52
-
53
- /**
54
- * Set SPiD server URL
55
- * @private
56
- * @param {string} url
57
- * @returns {void}
58
- */
59
- _setSpidServerUrl(url) {
60
- assert(isStr(url), `url parameter is invalid: ${url}`);
61
- this._spid = new RESTClient({
62
- serverUrl: urlMapper(url, ENDPOINTS.SPiD),
63
- defaultParams: { client_id: this.clientId, redirect_uri: this.redirectUri },
64
- });
65
- }
66
-
67
- /**
68
- * Set session-service domain
69
- * @private
70
- * @param {string} domain - real URL — (**not** 'PRE' style env key)
71
- * @returns {void}
72
- */
73
- _setSessionServiceUrl(domain) {
74
- assert(isStr(domain), `domain parameter is invalid: ${domain}`);
75
- const client_sdrn = `sdrn:${NAMESPACE[this.env]}:client:${this.clientId}`;
76
- this._sessionService = new RESTClient({
77
- serverUrl: domain,
78
- log: this.log,
79
- defaultParams: { client_sdrn, redirect_uri: this.redirectUri, sdk_version: version },
80
- });
71
+ registerAndDispatchInGlobal(window, 'schMonetization', this);
81
72
  }
82
73
 
83
74
  /**
84
75
  * Checks if the user has access to a set of products or features.
85
- * @param {array} productIds - which products/features to check
86
- * @param {number} userId - id of currently logged in user
76
+ * @param productIds - which products/features to check
77
+ * @param userId - id of currently logged in user
87
78
  * @throws {SDKError} - If the input is incorrect, or a network call fails in any way
88
79
  * (this will happen if, say, the user is not logged in)
89
- * @returns {Object|null} The data object returned from Schibsted account (or `null` if the user
80
+ * @returns The data object returned from Schibsted account (or `null` if the user
90
81
  * doesn't have access to any of the given products/features)
91
82
  */
92
- async hasAccess(productIds, userId) {
83
+ async hasAccess(productIds: string[], userId: SessionUserId): Promise<HasAccessResult | null> {
93
84
  if (!this._sessionService) {
94
85
  throw new SDKError(`hasAccess can only be called if 'sessionDomain' is configured`);
95
86
  }
@@ -102,10 +93,14 @@ export class Monetization extends EventEmitter {
102
93
 
103
94
  const sortedIds = [...productIds].sort();
104
95
  const cacheKey = this._accessCacheKey(sortedIds, userId);
105
- let data = this.cache.get(cacheKey);
96
+ let data = this.cache.get(cacheKey) as HasAccessResult | null;
106
97
  if (!data) {
107
98
  if (!this.pendingHasAccessRequests[cacheKey]) {
108
- this.pendingHasAccessRequests[cacheKey] = this._sessionService.get(`/hasAccess/${sortedIds.join(',')}`);
99
+ this.pendingHasAccessRequests[cacheKey] = this._sessionService.get(
100
+ `/hasAccess/${sortedIds.join(',')}`,
101
+ undefined,
102
+ parseHasAccessResult,
103
+ );
109
104
  }
110
105
  const promise = this.pendingHasAccessRequests[cacheKey];
111
106
  try {
@@ -113,7 +108,6 @@ export class Monetization extends EventEmitter {
113
108
  const expiresSeconds = data.ttl;
114
109
  this.cache.set(cacheKey, data, expiresSeconds * 1000);
115
110
  } finally {
116
- // If it rejects, we still want to clear the pending request
117
111
  if (this.pendingHasAccessRequests[cacheKey] === promise) {
118
112
  delete this.pendingHasAccessRequests[cacheKey];
119
113
  }
@@ -129,41 +123,39 @@ export class Monetization extends EventEmitter {
129
123
 
130
124
  /**
131
125
  * Removes the cached access result.
132
- * @param {array} productIds - which products/features to check
133
- * @param {number} userId - id of currently logged in user
134
- * @returns {void}
126
+ * @param productIds - which products/features to check
127
+ * @param userId - id of currently logged in user
135
128
  */
136
- clearCachedAccessResult(productIds, userId) {
129
+ clearCachedAccessResult(productIds: string[], userId: SessionUserId): void {
137
130
  this.cache.delete(this._accessCacheKey(productIds, userId));
138
131
  }
139
132
 
140
133
  /**
141
134
  * Compute "has access" cache key for the given product ids and user id.
142
- * @param {array} productIds - which products/features to check
143
- * @param {number} userId - id of currently logged in user
144
- * @returns {string}
145
135
  * @private
136
+ * @param productIds - which products/features to check
137
+ * @param userId - id of currently logged in user
146
138
  */
147
- _accessCacheKey(productIds, userId) {
139
+ private _accessCacheKey(productIds: string[], userId: SessionUserId): string {
148
140
  return `prd_${[...productIds].sort()}_${userId}`;
149
141
  }
150
142
 
151
143
  /**
152
144
  * Get the url for the end user to review the subscriptions
153
- * @param {string} [redirectUri=this.redirectUri]
154
- * @return {string} - The url to the subscriptions review page
145
+ * @param redirectUri
146
+ * @returns - The url to the subscriptions review page
155
147
  */
156
- subscriptionsUrl(redirectUri = this.redirectUri) {
148
+ subscriptionsUrl(redirectUri: string = this.redirectUri ?? ''): string {
157
149
  assert(isUrl(redirectUri), `subscriptionsUrl(): redirectUri is invalid`);
158
150
  return this._spid.makeUrl('account/subscriptions', { redirect_uri: redirectUri });
159
151
  }
160
152
 
161
153
  /**
162
154
  * Get the url for the end user to review the products
163
- * @param {string} [redirectUri=this.redirectUri]
164
- * @return {string} - The url to the products review page
155
+ * @param redirectUri
156
+ * @returns - The url to the products review page
165
157
  */
166
- productsUrl(redirectUri = this.redirectUri) {
158
+ productsUrl(redirectUri: string = this.redirectUri ?? ''): string {
167
159
  assert(isUrl(redirectUri), `productsUrl(): redirectUri is invalid`);
168
160
  return this._spid.makeUrl('account/products', { redirect_uri: redirectUri });
169
161
  }
@@ -2,41 +2,36 @@
2
2
  * See LICENSE.md in the project root.
3
3
  */
4
4
 
5
- 'use strict';
6
-
7
5
  /**
8
6
  * @summary Some routines that work on javascript objects
9
7
  * @private
10
8
  */
11
9
 
12
- import { assert, isObject, isNonEmptyObj } from './validate.js';
13
- import SDKError from './SDKError.js';
10
+ import SDKError from './sdk-error.js';
11
+ import { assert, isNonEmptyObj, isObject } from './validate.js';
14
12
 
15
13
  /**
16
14
  * Similar to Object.assign({}, src) but only clones the keys of an object that have non-undefined
17
15
  * values.
18
16
  * Please note that the values in the rightmost parameters can overwrite the values of the earlier
19
17
  * parameters so the order of the parameters matters.
20
- * @memberof core
21
18
  * @example
22
19
  * cloneDefined({foo: 1, bar: 2}, {foo: 2}) // returns {foo: 2, bar: 2}
23
20
  *
24
- * @param {object} sources - one or more sources. Their defined properties will override the ones
21
+ * @param sources - one or more sources. Their defined properties will override the ones
25
22
  * that came before it. For example if `source1.foo = 'bar'` and `source2.foo = 'baz'`, the
26
23
  * result will include `{ foo: 'baz' }`
27
- * @return {object} a new object that is similar to src with all the key/values where the
28
- * keys for undefined values are removed.
29
24
  */
30
- export function cloneDefined(...sources) {
31
- const result = {};
25
+ export function cloneDefined(...sources: object[]): Record<string, unknown> {
26
+ const result: Record<string, unknown> = {};
32
27
  if (!(sources && sources.length)) {
33
28
  throw new SDKError('No objects to clone');
34
29
  }
35
- sources.forEach(source => {
30
+ sources.forEach((source) => {
36
31
  assert(isObject(source));
37
32
  if (isNonEmptyObj(source)) {
38
33
  Object.entries(source).forEach(([key, value]) => {
39
- if (typeof value !== 'undefined' ) {
34
+ if (typeof value !== 'undefined') {
40
35
  result[key] = isObject(value) ? cloneDeep(value) : value;
41
36
  }
42
37
  });
@@ -47,13 +42,11 @@ export function cloneDefined(...sources) {
47
42
 
48
43
  /**
49
44
  * Deep copies an object. This is handy for immutability.
50
- * @memberof core
51
- * @param {object} obj - An object, array or null.
52
- * @return {object} - an exact copy of the object but deep copied
45
+ * @param obj - An object, array or null.
53
46
  * @throws {SDKError} - if the obj is not an accepted type or is not
54
47
  * stringifiable by JSON for example if it has loops
55
48
  */
56
- export function cloneDeep(obj) {
49
+ export function cloneDeep<T extends object | null>(obj: T): T {
57
50
  assert(typeof obj === 'object', `obj should be an object (even null) but it is ${obj}`);
58
51
  return JSON.parse(JSON.stringify(obj)) || obj;
59
52
  }
package/src/popup.ts ADDED
@@ -0,0 +1,74 @@
1
+ /* Copyright 2024 Schibsted Products & Technology AS. Licensed under the terms of the MIT license.
2
+ * See LICENSE.md in the project root.
3
+ */
4
+
5
+ import { cloneDefined } from './object.js';
6
+ import { assert, isFunction, isObject, isUrl } from './validate.js';
7
+
8
+ /**
9
+ * Serializes an object to string.
10
+ * @private
11
+ * @param obj - for example {a: 'b', c: 1}
12
+ */
13
+ function serialize(obj: Record<string, unknown>): string {
14
+ assert(isObject(obj), `Object must be an object but it is '${obj}'`);
15
+ return Object.keys(obj)
16
+ .map((key) => `${key}=${obj[key]}`)
17
+ .join(',');
18
+ }
19
+
20
+ const defaultWindowFeatures = {
21
+ scrollbars: 'yes',
22
+ location: 'yes',
23
+ status: 'no',
24
+ menubar: 'no',
25
+ toolbar: 'no',
26
+ resizable: 'yes',
27
+ };
28
+
29
+ /**
30
+ * Opens a popup
31
+ * @param parentWindow - A reference to the window that will open the popup
32
+ * @param url - The URL that the popup will open
33
+ * @param windowName - A name for the window
34
+ * @param windowFeatures - Window features for the popup (default ones are usually ok)
35
+ * @private
36
+ */
37
+ export function open(
38
+ parentWindow: Window,
39
+ url: string,
40
+ windowName = '',
41
+ windowFeatures: Record<string, string | number> = {},
42
+ ): Window | null {
43
+ assert(isObject(parentWindow), `window was supposed to be an object but it is ${parentWindow}`);
44
+ assert(
45
+ isObject(parentWindow.screen),
46
+ `window should be a valid Window object but it lacks a 'screen' property`,
47
+ );
48
+ assert(
49
+ isFunction(parentWindow.open),
50
+ `window should be a valid Window object but it lacks an 'open' function`,
51
+ );
52
+ assert(isUrl(url), 'Invalid URL for popup');
53
+
54
+ const { height, width } = parentWindow.screen;
55
+
56
+ const mergedFeatures = cloneDefined(defaultWindowFeatures, windowFeatures);
57
+ const { width: featureWidth, height: featureHeight } = mergedFeatures;
58
+ if (
59
+ typeof featureWidth === 'number' &&
60
+ Number.isFinite(featureWidth) &&
61
+ Number.isFinite(width)
62
+ ) {
63
+ mergedFeatures.left = (width - featureWidth) / 2;
64
+ }
65
+ if (
66
+ typeof featureHeight === 'number' &&
67
+ Number.isFinite(featureHeight) &&
68
+ Number.isFinite(height)
69
+ ) {
70
+ mergedFeatures.top = (height - featureHeight) / 2;
71
+ }
72
+ const features = serialize(mergedFeatures);
73
+ return parentWindow.open(url, windowName, features);
74
+ }
@@ -0,0 +1,11 @@
1
+ /* Copyright 2026 Schibsted Products & Technology AS. Licensed under the terms of the MIT license.
2
+ * See LICENSE.md in the project root.
3
+ */
4
+
5
+ export const resolveBrowserWindow = (providedWindow?: Window): Window | undefined => {
6
+ if (providedWindow) {
7
+ return providedWindow;
8
+ }
9
+
10
+ return typeof window === 'undefined' ? undefined : window;
11
+ };
@@ -0,0 +1,253 @@
1
+ /* Copyright 2024 Schibsted Products & Technology AS. Licensed under the terms of the MIT license.
2
+ * See LICENSE.md in the project root.
3
+ */
4
+
5
+ import { cloneDefined } from './object.js';
6
+ import SDKError from './sdk-error.js';
7
+ import type { LogFunction } from './types/common.js';
8
+ import { urlMapper } from './url.js';
9
+ import { assert, isFunction, isNonEmptyString, isObject, isStr } from './validate.js';
10
+
11
+ /**
12
+ * Converts a series of parameters of various types to a string that's suitable for logging.
13
+ * @private
14
+ * @param msg - The values to format; objects are stringified to JSON
15
+ */
16
+ const logString = (msg: unknown[]): string =>
17
+ msg.map((m) => (isObject(m) ? JSON.stringify(m, null, 2) : m)).join(' ');
18
+
19
+ const logFn = (fn: LogFunction | undefined, ...msg: unknown[]): void => {
20
+ if (fn) {
21
+ fn(logString(msg));
22
+ }
23
+ };
24
+
25
+ /**
26
+ * Encode a string like URLSearchParams would do
27
+ * @private
28
+ * @param str - The input
29
+ */
30
+ function encode(str: string): string {
31
+ const replace: Record<string, string> = {
32
+ '!': '%21',
33
+ "'": '%27',
34
+ '(': '%28',
35
+ ')': '%29',
36
+ '~': '%7E',
37
+ '%20': '+',
38
+ '%00': '\x00',
39
+ };
40
+ return encodeURIComponent(str).replace(/[!'()~]|%20|%00/g, (match) => replace[match]);
41
+ }
42
+ type FetchFunction = (input: RequestInfo, init?: RequestInit) => Promise<Response>;
43
+ const globalFetch = () => window.fetch && window.fetch.bind(window);
44
+ const noFetch: FetchFunction = () => {
45
+ throw new SDKError('fetch is not available in this environment');
46
+ };
47
+
48
+ /**
49
+ * The value of a single query parameter; `undefined` entries are skipped.
50
+ */
51
+ type QueryValue = string | number | boolean | undefined;
52
+
53
+ /**
54
+ * Query parameters appended to a request URL.
55
+ */
56
+ type QueryParams = Record<string, QueryValue>;
57
+
58
+ /**
59
+ * HTTP method used for a request.
60
+ */
61
+ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
62
+
63
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
64
+
65
+ type ResponseParser<T> = (value: JsonValue) => T;
66
+
67
+ type RESTRequestOptions<T = JsonValue> = {
68
+ method: HttpMethod;
69
+ pathname: string;
70
+ data?: QueryParams;
71
+ headers?: Record<string, string>;
72
+ useDefaultParams?: boolean;
73
+ fetchOptions?: RequestInit;
74
+ parseResponse?: ResponseParser<T>;
75
+ };
76
+
77
+ /**
78
+ * This class can be used for creating a wrapper around a server and all its endpoints.
79
+ * Its functionality is extended by {@link JSONPClient}
80
+ * Creates a client to a REST server. While useful stand-alone, it's also used for some other
81
+ * types of client that change some functionalities.
82
+ * @throws {SDKError} - If any of options are invalid
83
+ * @summary The simplest way to communicate to a REST endpoint without any
84
+ * special authentication
85
+ * @private
86
+ */
87
+ export class RESTClient {
88
+ url: URL;
89
+ defaultParams: QueryParams;
90
+ log?: LogFunction;
91
+ fetch: FetchFunction;
92
+
93
+ /**
94
+ * @param options
95
+ * @param options.serverUrl - The URL to the server eg.
96
+ * https://login.schibsted.com or a URL key like 'DEV' in combination with {@link envDic}.
97
+ * @param options.envDic - A dictionary that will be used for looking up
98
+ * {@link serverUrl} keys. If serverUrl is always a URL, you don't need this.
99
+ * @param options.fetch - The fetch function to use. It can be native
100
+ * or a polyfill
101
+ * @param options.log - A function that will be called with log messages about
102
+ * request and response
103
+ * @param options.defaultParams - Query parameters added to every request
104
+ */
105
+ constructor({
106
+ serverUrl = 'PRE',
107
+ envDic,
108
+ fetch = globalFetch() ?? noFetch,
109
+ log,
110
+ defaultParams = {},
111
+ }: {
112
+ serverUrl?: string;
113
+ envDic?: Record<string, string>;
114
+ fetch?: FetchFunction;
115
+ log?: LogFunction;
116
+ defaultParams?: QueryParams;
117
+ }) {
118
+ assert(isObject(defaultParams), `defaultParams should be a non-null object`);
119
+
120
+ const mappedUrl = urlMapper(serverUrl, envDic ?? {});
121
+ const handledServerUrl = mappedUrl.endsWith('/') ? mappedUrl : `${mappedUrl}/`;
122
+ this.url = new URL(handledServerUrl);
123
+
124
+ this.defaultParams = defaultParams;
125
+
126
+ if (log) {
127
+ assert(isFunction(log), `log must be a function but it is ${log}`);
128
+ this.log = log;
129
+ }
130
+
131
+ this.fetch = fetch;
132
+ }
133
+
134
+ /**
135
+ * Makes the actual call to the server and deals with headers, data objects and the edge cases.
136
+ * Please note that this method expects the response to be in JSON format. However, it'll not
137
+ * parse the response if its code is not in the 200 range.
138
+ * @param options - An obligatory options object
139
+ * @param options.method - The HTTP request method, e.g. 'GET' or 'POST'
140
+ * @param options.pathname - The path to the endpoint like 'api/2/endpoint-name'
141
+ * @param options.data - Query parameters added to the request URL
142
+ * @param options.headers - Request headers as a plain key/value map
143
+ * @param options.useDefaultParams - Should we add the defaultParams to the query?
144
+ * @param options.fetchOptions - Additional fetch options
145
+ * @throws {SDKError} - If the call can't be made for whatever reason.
146
+ */
147
+ // Overloads describe the two supported modes:
148
+ // - without a parser: return the parsed JSON response as-is;
149
+ // - with a parser: return the parser's typed result.
150
+ async go(options: RESTRequestOptions): Promise<JsonValue>;
151
+ async go<T>(options: RESTRequestOptions<T> & { parseResponse: ResponseParser<T> }): Promise<T>;
152
+
153
+ // TypeScript requires one implementation signature for the overloads above.
154
+ async go<T = JsonValue>(options: RESTRequestOptions<T>): Promise<JsonValue | T> {
155
+ const {
156
+ method,
157
+ headers,
158
+ pathname,
159
+ data = {},
160
+ useDefaultParams = true,
161
+ fetchOptions = { method: options.method, credentials: 'include' },
162
+ parseResponse,
163
+ } = options;
164
+ assert(isNonEmptyString(method), `Method must be a non empty string but it is "${method}"`);
165
+ assert(isNonEmptyString(pathname), `Pathname must be string but it is "${pathname}"`);
166
+ assert(isObject(data), `data must be a non-null object`);
167
+
168
+ fetchOptions.headers = isObject(headers) ? headers : {};
169
+ const fullUrl = this.makeUrl(pathname, data, useDefaultParams);
170
+
171
+ logFn(this.log, 'Request:', method.toUpperCase(), fullUrl);
172
+ logFn(this.log, 'Request Headers:', fetchOptions.headers);
173
+ logFn(this.log, 'Request Body:', fetchOptions.body);
174
+ try {
175
+ const response = await this.fetch(fullUrl, fetchOptions);
176
+ logFn(this.log, 'Response Code:', response.status, response.statusText);
177
+ if (!response.ok) {
178
+ // status code not in range 200-299
179
+ throw new SDKError(response.statusText, { code: response.status });
180
+ }
181
+ const responseObject: JsonValue = await response.json();
182
+ logFn(this.log, 'Response Parsed:', responseObject);
183
+ return parseResponse ? parseResponse(responseObject) : responseObject;
184
+ } catch (err: unknown) {
185
+ let msg = isStr(err) ? err : 'Unknown RESTClient error';
186
+ if (isObject(err) && isStr(err.message)) {
187
+ msg = err.message;
188
+ }
189
+ const errorObject = isObject(err) ? err : undefined;
190
+ throw new SDKError(`Failed to '${method}' '${fullUrl}': '${msg}'`, errorObject);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Creates a url that points to an endpoint in the server
196
+ * @param pathname - WHATWG pathname ie. 'api/2/endpoint-name'
197
+ * @param query - Query parameters to serialize into the query string
198
+ * @param useDefaultParams - Should we add the defaultParams to the query?
199
+ */
200
+ makeUrl(pathname = '', query: QueryParams = {}, useDefaultParams = true): string {
201
+ const handledPathname = pathname.startsWith('/') ? pathname.slice(1) : pathname;
202
+
203
+ const url = new URL(handledPathname, this.url);
204
+ url.search = RESTClient.search(query, useDefaultParams, this.defaultParams);
205
+ return url.href;
206
+ }
207
+
208
+ /**
209
+ * Make a GET request
210
+ * @param pathname - WHATWG pathname ie. 'api/2/endpoint-name'
211
+ * @param data - Query parameters
212
+ */
213
+ // Mirrors `go()` overloads for the common GET helper.
214
+ get(pathname: string, data?: QueryParams): Promise<JsonValue>;
215
+ get<T>(
216
+ pathname: string,
217
+ data: QueryParams | undefined,
218
+ parseResponse: ResponseParser<T>,
219
+ ): Promise<T>;
220
+
221
+ // TypeScript requires one implementation signature for the overloads above.
222
+ get<T>(
223
+ pathname: string,
224
+ data?: QueryParams,
225
+ parseResponse?: ResponseParser<T>,
226
+ ): Promise<JsonValue | T> {
227
+ if (parseResponse) {
228
+ return this.go({ method: 'GET', pathname, data, parseResponse });
229
+ }
230
+ return this.go({ method: 'GET', pathname, data });
231
+ }
232
+
233
+ /**
234
+ * Construct query string for WHATWG urls
235
+ * @private
236
+ * @param query - Query parameters to generate the query string from
237
+ * @param useDefaultParams - Use defaultParams or not
238
+ * @param defaultParams - Default params
239
+ */
240
+ private static search(
241
+ query: QueryParams,
242
+ useDefaultParams: boolean,
243
+ defaultParams: QueryParams,
244
+ ): string {
245
+ const params = useDefaultParams ? cloneDefined(defaultParams, query) : cloneDefined(query);
246
+ return Object.keys(params)
247
+ .filter((p) => params[p] !== '')
248
+ .map((p) => `${encode(p)}=${encode(String(params[p]))}`)
249
+ .join('&');
250
+ }
251
+ }
252
+
253
+ export default RESTClient;