@adobe/spacecat-shared-project-engine-client 1.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.
- package/LICENSE.txt +263 -0
- package/README.md +132 -0
- package/package.json +65 -0
- package/src/client.js +133 -0
- package/src/generated/types.ts +8373 -0
- package/src/index.d.ts +70 -0
- package/src/index.js +13 -0
- package/src/internal.js +249 -0
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Client } from 'openapi-fetch';
|
|
14
|
+
import type { paths, components } from './generated/types.js';
|
|
15
|
+
|
|
16
|
+
/** Supplies the caller's IMS JWT — forwarded verbatim, never minted or exchanged. */
|
|
17
|
+
export type AuthTokenSource = string | (() => string | Promise<string>);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A fully-typed Project Engine client over every operation in the generated `paths`.
|
|
21
|
+
* The generated `paths` are already free of the legacy `Auth-Data-Jwt` header (the live API
|
|
22
|
+
* authenticates on `Authorization: Bearer`), so no runtime header narrowing is required here.
|
|
23
|
+
*/
|
|
24
|
+
export type SerenityProjectEngineApiClient = Client<paths>;
|
|
25
|
+
|
|
26
|
+
export interface SerenityProjectEngineApiClientOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Base URL of the Project Engine API — the origin of `SEMRUSH_PROJECTS_BASE_URL`, or the
|
|
29
|
+
* Counterfact mock's origin for E2E / local dev. Only `protocol//host` is used; the client
|
|
30
|
+
* appends the fixed `/enterprise/projects/api` prefix itself.
|
|
31
|
+
*/
|
|
32
|
+
baseUrl: string;
|
|
33
|
+
/**
|
|
34
|
+
* The caller's IMS JWT, or a (sync/async) getter resolved per request. Sent as the
|
|
35
|
+
* `Authorization: Bearer <token>` header. The client performs NO token exchange or minting —
|
|
36
|
+
* Semrush accepts the IMS bearer token directly, so the caller's token is forwarded as-is.
|
|
37
|
+
*/
|
|
38
|
+
authToken: AuthTokenSource;
|
|
39
|
+
/** Retry attempts on 429 / retryable 5xx / network error. Default 2 (3 tries total). */
|
|
40
|
+
maxRetries?: number;
|
|
41
|
+
/** Base backoff in ms; grows exponentially per attempt. Default 200. */
|
|
42
|
+
retryBaseDelayMs?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Best-effort hook invoked before each retry sleep, for logging/metrics. A retry loop is
|
|
45
|
+
* otherwise silent. A throwing or rejecting hook is swallowed and never affects the request.
|
|
46
|
+
* May be async; it is fire-and-forget (never awaited) so it cannot delay a retry.
|
|
47
|
+
*/
|
|
48
|
+
onRetry?: (info: {
|
|
49
|
+
attempt: number;
|
|
50
|
+
delayMs: number;
|
|
51
|
+
method: string;
|
|
52
|
+
status?: number;
|
|
53
|
+
error?: Error;
|
|
54
|
+
}) => void | Promise<void>;
|
|
55
|
+
/** Injectable fetch (tests, custom agents). Defaults to the global fetch. */
|
|
56
|
+
fetch?: typeof globalThis.fetch;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates a thin, typed client over the generated Project Engine `paths`. It owns the base
|
|
61
|
+
* URL (origin + `/enterprise/projects/api`), retries, and authenticating each request with the
|
|
62
|
+
* caller's IMS JWT as `Authorization: Bearer` — and nothing else; request/response shapes come
|
|
63
|
+
* straight from the generated types.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createSerenityProjectEngineApiClient(
|
|
66
|
+
options: SerenityProjectEngineApiClientOptions,
|
|
67
|
+
): SerenityProjectEngineApiClient;
|
|
68
|
+
|
|
69
|
+
// Re-export the generated contract types for consumers that want them directly.
|
|
70
|
+
export type { paths, components };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { createSerenityProjectEngineApiClient } from './client.js';
|
package/src/internal.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Framework-agnostic building blocks for the Project Engine client.
|
|
15
|
+
* Deliberately free of `openapi-fetch` and generated-type imports so they can be
|
|
16
|
+
* unit-tested without the generated spec output being present.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Supplies the caller's IMS JWT — forwarded verbatim, never minted or exchanged.
|
|
21
|
+
* @typedef {string | (() => string | Promise<string>)} AuthTokenSource
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolves the HTTP method for a fetch call the same way the platform does.
|
|
28
|
+
* @param {RequestInfo | URL} input
|
|
29
|
+
* @param {RequestInit} [init]
|
|
30
|
+
* @returns {string} the upper-cased method
|
|
31
|
+
*/
|
|
32
|
+
export function methodOf(input, init) {
|
|
33
|
+
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
|
|
34
|
+
return method.toUpperCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} method
|
|
39
|
+
* @returns {boolean} whether the method is safe to replay
|
|
40
|
+
*/
|
|
41
|
+
export function isIdempotent(method) {
|
|
42
|
+
return IDEMPOTENT_METHODS.has(method);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 429 is retried for ANY method, including non-idempotent ones (POST). The assumption: the
|
|
47
|
+
* Semrush gateway rejects a rate-limited request at the edge, before it reaches the handler, so
|
|
48
|
+
* the create never happened and replaying it cannot duplicate a resource. This holds for the
|
|
49
|
+
* deployed API today, but it IS an upstream contract: if Semrush ever rate-limits *after*
|
|
50
|
+
* partially processing a write, a 429-retried POST (e.g. bulk-create-projects, create-brand-urls,
|
|
51
|
+
* create-prompt) could double-create. There is no idempotency-key header on these endpoints to
|
|
52
|
+
* lean on; revisit this method-agnostic 429 retry if that upstream behaviour changes. A 5xx, by
|
|
53
|
+
* contrast, is retried only for idempotent methods, so a POST that may have already created a
|
|
54
|
+
* resource is never replayed.
|
|
55
|
+
* @param {string} method
|
|
56
|
+
* @param {number} status
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function isRetryableStatus(method, status) {
|
|
60
|
+
if (status === 429) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if (status >= 500 && status <= 599) {
|
|
64
|
+
return isIdempotent(method);
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {number} ms
|
|
71
|
+
* @returns {Promise<void>}
|
|
72
|
+
*/
|
|
73
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
74
|
+
setTimeout(resolve, ms);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Upper bound on a single inter-attempt wait. Caps a hostile or fat-fingered `Retry-After`
|
|
79
|
+
* (and runaway exponential growth) so a retry can never hang a Lambda past a sane ceiling.
|
|
80
|
+
*/
|
|
81
|
+
export const MAX_RETRY_DELAY_MS = 20_000;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parses a `Retry-After` header into milliseconds. Supports both RFC 9110 forms: delta-seconds
|
|
85
|
+
* (e.g. `"5"`) and an HTTP-date. Returns null when the header is absent or unparseable, so the
|
|
86
|
+
* caller falls back to backoff.
|
|
87
|
+
* @param {Response} response
|
|
88
|
+
* @returns {number | null}
|
|
89
|
+
*/
|
|
90
|
+
export function parseRetryAfterMs(response) {
|
|
91
|
+
const raw = response.headers.get('retry-after');
|
|
92
|
+
if (!raw) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const seconds = Number(raw);
|
|
96
|
+
if (Number.isFinite(seconds)) {
|
|
97
|
+
return Math.max(0, Math.round(seconds * 1000));
|
|
98
|
+
}
|
|
99
|
+
const epochMs = Date.parse(raw);
|
|
100
|
+
if (Number.isNaN(epochMs)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return Math.max(0, epochMs - Date.now());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The wait before the next attempt: the larger of (a) exponential backoff with equal jitter —
|
|
108
|
+
* `baseDelayMs * 2 ** completedAttempt` scaled by a random factor in `[0.5, 1)` to de-correlate
|
|
109
|
+
* concurrent clients and avoid thundering-herd alignment on a shared 429/503 — and (b) the
|
|
110
|
+
* server's `Retry-After`, when present (so we never retry sooner than the server asked).
|
|
111
|
+
* Clamped to {@link MAX_RETRY_DELAY_MS}.
|
|
112
|
+
* @param {number} completedAttempt zero-based index of the attempt that just failed
|
|
113
|
+
* @param {number} baseDelayMs
|
|
114
|
+
* @param {Response | null} response the retryable response, if any (for `Retry-After`)
|
|
115
|
+
* @returns {number}
|
|
116
|
+
*/
|
|
117
|
+
export function nextRetryDelayMs(completedAttempt, baseDelayMs, response) {
|
|
118
|
+
const backoff = baseDelayMs * 2 ** completedAttempt;
|
|
119
|
+
const jittered = backoff * (0.5 + Math.random() * 0.5);
|
|
120
|
+
const retryAfter = response ? parseRetryAfterMs(response) : null;
|
|
121
|
+
const delay = retryAfter == null ? jittered : Math.max(jittered, retryAfter);
|
|
122
|
+
return Math.min(delay, MAX_RETRY_DELAY_MS);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Invokes a best-effort {@link OnRetry} hook, swallowing both synchronous throws and asynchronous
|
|
127
|
+
* rejections so a broken observability callback can never break the retry loop or the request.
|
|
128
|
+
* The hook fires fire-and-forget (never awaited), so it cannot delay a retry. Its own failures are
|
|
129
|
+
* deliberately silent (no signal is emitted) — surfacing them would itself need an observability
|
|
130
|
+
* channel; observability must never affect the request outcome.
|
|
131
|
+
* @param {OnRetry} [onRetry]
|
|
132
|
+
* @param {object} info
|
|
133
|
+
*/
|
|
134
|
+
function notifyRetry(onRetry, info) {
|
|
135
|
+
if (!onRetry) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const result = onRetry(info);
|
|
140
|
+
// An async onRetry returns a promise; sink its rejection here so a rejecting hook can't escape
|
|
141
|
+
// as an unhandled promise rejection (which crashes the process in Node 18+). Not awaited.
|
|
142
|
+
if (result && typeof result.catch === 'function') {
|
|
143
|
+
result.catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// best-effort: a throwing (sync) hook is swallowed, same as a rejecting (async) one above.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @callback OnRetry
|
|
152
|
+
* @param {object} info
|
|
153
|
+
* @param {number} info.attempt the 1-based number of the retry about to be made
|
|
154
|
+
* @param {number} info.delayMs the wait before this retry
|
|
155
|
+
* @param {string} info.method the HTTP method
|
|
156
|
+
* @param {number} [info.status] the retryable response status that triggered the retry, if any
|
|
157
|
+
* @param {Error} [info.error] the network error that triggered the retry, if any
|
|
158
|
+
* @returns {void | Promise<void>} may be async; the return is not awaited (fire-and-forget)
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Wraps a fetch with bounded exponential-backoff retries. Retryable statuses follow
|
|
163
|
+
* {@link isRetryableStatus}; thrown network errors are retried only for idempotent methods.
|
|
164
|
+
* The wait between attempts is {@link nextRetryDelayMs} — jittered exponential backoff that also
|
|
165
|
+
* honours a `Retry-After` header. After exhausting retries it returns the last retryable response
|
|
166
|
+
* (so the caller still sees e.g. the final 503) or rethrows the last network error.
|
|
167
|
+
*
|
|
168
|
+
* An optional `onRetry` callback is invoked just before each retry sleep, so consumers can log or
|
|
169
|
+
* meter retry behaviour (otherwise a retry loop silently delays a response by up to
|
|
170
|
+
* `maxRetries * MAX_RETRY_DELAY_MS`). It is best-effort and fire-and-forget: a throwing (sync) or
|
|
171
|
+
* rejecting (async) `onRetry` is swallowed so a broken observability hook can never break the
|
|
172
|
+
* request itself.
|
|
173
|
+
* @param {typeof globalThis.fetch} baseFetch
|
|
174
|
+
* @param {number} maxRetries
|
|
175
|
+
* @param {number} baseDelayMs
|
|
176
|
+
* @param {OnRetry} [onRetry] optional best-effort retry-observability hook
|
|
177
|
+
* @returns {typeof globalThis.fetch}
|
|
178
|
+
*/
|
|
179
|
+
export function createRetryingFetch(baseFetch, maxRetries, baseDelayMs, onRetry) {
|
|
180
|
+
return async function retryingFetch(input, init) {
|
|
181
|
+
const method = methodOf(input, init);
|
|
182
|
+
// Floor at 0: a negative maxRetries would skip the loop entirely, leaving both lastResponse
|
|
183
|
+
// and lastError undefined and ending in `throw undefined`. Degrade to a single attempt instead.
|
|
184
|
+
const attempts = Math.max(0, maxRetries);
|
|
185
|
+
// openapi-fetch calls us with a Request object; fetch() consumes its body on use, so a
|
|
186
|
+
// bare replay throws "Request ... already used". Clone per attempt and never touch the
|
|
187
|
+
// original, so every retry (incl. a 429 on a bodied POST) sends a fresh, unconsumed body.
|
|
188
|
+
// The clone preserves the request's headers — including the `Authorization` header the auth
|
|
189
|
+
// middleware set once for this logical request — so all attempts share that one token. The
|
|
190
|
+
// token is resolved per request, not per attempt; with the ceiling above the whole loop is
|
|
191
|
+
// bounded well under an IMS token's lifetime, so mid-loop expiry is a non-issue.
|
|
192
|
+
const forAttempt = () => (input instanceof Request ? input.clone() : input);
|
|
193
|
+
let lastResponse;
|
|
194
|
+
let lastError;
|
|
195
|
+
let nextDelayMs = 0;
|
|
196
|
+
|
|
197
|
+
for (let attempt = 0; attempt <= attempts; attempt += 1) {
|
|
198
|
+
if (attempt > 0) {
|
|
199
|
+
notifyRetry(onRetry, {
|
|
200
|
+
attempt, delayMs: nextDelayMs, method, status: lastResponse?.status, error: lastError,
|
|
201
|
+
});
|
|
202
|
+
// eslint-disable-next-line no-await-in-loop
|
|
203
|
+
await sleep(nextDelayMs);
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
// eslint-disable-next-line no-await-in-loop
|
|
207
|
+
const response = await baseFetch(forAttempt(), init);
|
|
208
|
+
if (!isRetryableStatus(method, response.status)) {
|
|
209
|
+
return response;
|
|
210
|
+
}
|
|
211
|
+
lastResponse = response;
|
|
212
|
+
lastError = undefined;
|
|
213
|
+
nextDelayMs = nextRetryDelayMs(attempt, baseDelayMs, response);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (!isIdempotent(method)) {
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
lastError = error;
|
|
219
|
+
lastResponse = undefined;
|
|
220
|
+
nextDelayMs = nextRetryDelayMs(attempt, baseDelayMs, null);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (lastResponse) {
|
|
225
|
+
return lastResponse;
|
|
226
|
+
}
|
|
227
|
+
throw lastError;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Normalises an {@link AuthTokenSource} into a getter, so callers can pass either a static
|
|
233
|
+
* token or a (sync/async) function resolved per request. Rejects any other type at construction
|
|
234
|
+
* time — without this guard a stray `null`, number, or object would flow into the
|
|
235
|
+
* `Authorization` header (`Bearer [object Object]`) and surface only as an opaque upstream 401.
|
|
236
|
+
* @param {AuthTokenSource} source
|
|
237
|
+
* @returns {() => string | Promise<string>}
|
|
238
|
+
*/
|
|
239
|
+
export function toTokenGetter(source) {
|
|
240
|
+
if (typeof source === 'function') {
|
|
241
|
+
return source;
|
|
242
|
+
}
|
|
243
|
+
if (typeof source === 'string') {
|
|
244
|
+
return () => source;
|
|
245
|
+
}
|
|
246
|
+
throw new Error(
|
|
247
|
+
`Project Engine client: authToken must be a string or a function, got ${typeof source}`,
|
|
248
|
+
);
|
|
249
|
+
}
|