@abapify/adt-client 0.4.0 → 0.4.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.
- package/README.md +2 -0
- package/dist/index.d.mts +578 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1553 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +5 -4
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1553 @@
|
|
|
1
|
+
import { exception } from "@abapify/adt-schemas";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { Agent, fetch } from "undici";
|
|
4
|
+
import { adtContract, createAdtClient as createAdtClient$1 } from "@abapify/adt-contracts";
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
//#region src/utils/session.ts
|
|
9
|
+
/**
|
|
10
|
+
* Cookie Store - Manages HTTP cookies for stateful sessions
|
|
11
|
+
*/
|
|
12
|
+
var CookieStore = class {
|
|
13
|
+
cookies = /* @__PURE__ */ new Map();
|
|
14
|
+
/**
|
|
15
|
+
* Parse Set-Cookie header and store cookies
|
|
16
|
+
* Handles complex cookie strings with expires dates
|
|
17
|
+
*/
|
|
18
|
+
parseCookies(setCookieHeader) {
|
|
19
|
+
const cookieStrings = this.splitCookieHeader(setCookieHeader);
|
|
20
|
+
for (const cookieString of cookieStrings) {
|
|
21
|
+
const [name, value] = cookieString.split(";")[0].trim().split("=", 2);
|
|
22
|
+
if (name && value && !name.includes("expires") && !name.includes("path")) this.cookies.set(name.trim(), value.trim());
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Split Set-Cookie header by commas, avoiding splitting on expires dates
|
|
27
|
+
*/
|
|
28
|
+
splitCookieHeader(setCookieHeader) {
|
|
29
|
+
const cookies = [];
|
|
30
|
+
let current = "";
|
|
31
|
+
let inExpires = false;
|
|
32
|
+
for (let i = 0; i < setCookieHeader.length; i++) {
|
|
33
|
+
const char = setCookieHeader[i];
|
|
34
|
+
if (char === "," && !inExpires) {
|
|
35
|
+
if (current.trim()) cookies.push(current.trim());
|
|
36
|
+
current = "";
|
|
37
|
+
} else {
|
|
38
|
+
current += char;
|
|
39
|
+
if (current.toLowerCase().includes("expires=")) inExpires = true;
|
|
40
|
+
if (inExpires && (char === ";" || i === setCookieHeader.length - 1)) inExpires = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (current.trim()) cookies.push(current.trim());
|
|
44
|
+
return cookies;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get Cookie header value for requests
|
|
48
|
+
*/
|
|
49
|
+
getCookieHeader() {
|
|
50
|
+
if (this.cookies.size === 0) return;
|
|
51
|
+
return Array.from(this.cookies.entries()).map(([name, value]) => `${name}=${value}`).join("; ");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Check if we have any cookies
|
|
55
|
+
*/
|
|
56
|
+
hasCookies() {
|
|
57
|
+
return this.cookies.size > 0;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Clear all cookies
|
|
61
|
+
*/
|
|
62
|
+
clear() {
|
|
63
|
+
this.cookies.clear();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Get all cookies as a map (for testing/debugging)
|
|
67
|
+
*/
|
|
68
|
+
getAll() {
|
|
69
|
+
return new Map(this.cookies);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Inject pre-existing cookies (e.g., from SAML authentication)
|
|
73
|
+
* @param cookieString Cookie string in "name=value; name2=value2" format
|
|
74
|
+
*/
|
|
75
|
+
injectCookie(cookieString) {
|
|
76
|
+
const cookies = cookieString.split("; ");
|
|
77
|
+
for (const cookie of cookies) {
|
|
78
|
+
const [name, ...valueParts] = cookie.split("=");
|
|
79
|
+
const value = valueParts.join("=");
|
|
80
|
+
if (name && value) this.cookies.set(name.trim(), value.trim());
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* CSRF Token Manager - Handles CSRF token caching and initialization
|
|
86
|
+
*/
|
|
87
|
+
var CsrfTokenManager = class {
|
|
88
|
+
cachedToken;
|
|
89
|
+
/**
|
|
90
|
+
* Extract CSRF token from cookies (SAP stores it there)
|
|
91
|
+
*/
|
|
92
|
+
extractFromCookies(cookies) {
|
|
93
|
+
const xsrfEntry = Array.from(cookies.entries()).find(([key]) => key.toLowerCase().includes("xsrf") || key.toLowerCase().includes("csrf"));
|
|
94
|
+
if (!xsrfEntry) return;
|
|
95
|
+
const cookieValue = xsrfEntry[1];
|
|
96
|
+
const decodedToken = decodeURIComponent(cookieValue);
|
|
97
|
+
const tokenMatch = decodedToken.match(/^([A-Za-z0-9+/_-]+=*)/);
|
|
98
|
+
const actualToken = tokenMatch ? tokenMatch[1] : decodedToken;
|
|
99
|
+
if (actualToken && actualToken !== "Required" && actualToken !== "fetch" && actualToken !== "Fetch") return actualToken;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Extract CSRF token from response header
|
|
103
|
+
*/
|
|
104
|
+
extractFromHeader(headerValue) {
|
|
105
|
+
if (!headerValue || headerValue === "Required" || headerValue === "fetch" || headerValue === "Fetch") return;
|
|
106
|
+
return headerValue;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Cache a CSRF token
|
|
110
|
+
*/
|
|
111
|
+
cache(token) {
|
|
112
|
+
this.cachedToken = token;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Get cached CSRF token
|
|
116
|
+
*/
|
|
117
|
+
getCached() {
|
|
118
|
+
return this.cachedToken;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Clear cached CSRF token (e.g., on 403 errors)
|
|
122
|
+
*/
|
|
123
|
+
clear() {
|
|
124
|
+
this.cachedToken = void 0;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Check if we have a cached token
|
|
128
|
+
*/
|
|
129
|
+
hasCached() {
|
|
130
|
+
return !!this.cachedToken;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Alias for hasCached() - for consistency with SessionManager API
|
|
134
|
+
*/
|
|
135
|
+
hasToken() {
|
|
136
|
+
return this.hasCached();
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* ETag Manager - Tracks ETags for optimistic locking
|
|
141
|
+
*/
|
|
142
|
+
var ETagManager = class {
|
|
143
|
+
etags = /* @__PURE__ */ new Map();
|
|
144
|
+
/**
|
|
145
|
+
* Extract and cache ETag from response header
|
|
146
|
+
* @param url The request URL (used as key)
|
|
147
|
+
* @param headerValue The ETag header value
|
|
148
|
+
*/
|
|
149
|
+
cacheFromHeader(url, headerValue) {
|
|
150
|
+
if (headerValue) {
|
|
151
|
+
const key = this.normalizeUrl(url);
|
|
152
|
+
this.etags.set(key, headerValue);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get cached ETag for a URL
|
|
157
|
+
*/
|
|
158
|
+
get(url) {
|
|
159
|
+
const key = this.normalizeUrl(url);
|
|
160
|
+
return this.etags.get(key);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Clear cached ETag for a URL
|
|
164
|
+
*/
|
|
165
|
+
clear(url) {
|
|
166
|
+
if (url) {
|
|
167
|
+
const key = this.normalizeUrl(url);
|
|
168
|
+
this.etags.delete(key);
|
|
169
|
+
} else this.etags.clear();
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Normalize URL to use as cache key.
|
|
173
|
+
* Removes query params, trailing slashes, and lowercases the path.
|
|
174
|
+
* SAP ADT URLs are case-insensitive, so /fmodules/ZAGE_FM and
|
|
175
|
+
* /fmodules/zage_fm must share the same ETag cache entry.
|
|
176
|
+
*/
|
|
177
|
+
normalizeUrl(url) {
|
|
178
|
+
try {
|
|
179
|
+
return new URL(url).pathname.replace(/\/$/, "").toLowerCase();
|
|
180
|
+
} catch {
|
|
181
|
+
return url.split("?")[0].replace(/\/$/, "").toLowerCase();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Session Manager - Orchestrates cookies and CSRF tokens
|
|
187
|
+
*/
|
|
188
|
+
var SessionManager = class SessionManager {
|
|
189
|
+
logger;
|
|
190
|
+
fetchFn;
|
|
191
|
+
static SESSION_PATH_PREFIX = "/sap/bc/adt/core/http/sessions/";
|
|
192
|
+
cookieStore = new CookieStore();
|
|
193
|
+
csrfManager = new CsrfTokenManager();
|
|
194
|
+
etagManager = new ETagManager();
|
|
195
|
+
securitySessionActive = false;
|
|
196
|
+
constructor(logger, fetchFn = globalThis.fetch) {
|
|
197
|
+
this.logger = logger;
|
|
198
|
+
this.fetchFn = fetchFn;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Process response to update session state
|
|
202
|
+
* Extracts cookies, CSRF tokens, and ETags
|
|
203
|
+
* @param response The HTTP response
|
|
204
|
+
* @param url Optional URL for ETag caching (required for ETag tracking)
|
|
205
|
+
*/
|
|
206
|
+
processResponse(response, url) {
|
|
207
|
+
if (url) {
|
|
208
|
+
const etag = response.headers.get("etag");
|
|
209
|
+
if (etag) {
|
|
210
|
+
this.etagManager.cacheFromHeader(url, etag);
|
|
211
|
+
this.logger?.debug(`Session: ETag cached for ${url}: ${etag}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const setCookieHeader = response.headers.get("set-cookie");
|
|
215
|
+
if (setCookieHeader) {
|
|
216
|
+
this.cookieStore.parseCookies(setCookieHeader);
|
|
217
|
+
this.logger?.debug("Session: Cookies updated from response");
|
|
218
|
+
}
|
|
219
|
+
const csrfHeader = response.headers.get("x-csrf-token");
|
|
220
|
+
const csrfToken = this.csrfManager.extractFromHeader(csrfHeader);
|
|
221
|
+
if (csrfToken) {
|
|
222
|
+
this.csrfManager.cache(csrfToken);
|
|
223
|
+
this.logger?.debug("Session: CSRF token cached from header");
|
|
224
|
+
}
|
|
225
|
+
if (!csrfToken && this.cookieStore.hasCookies()) {
|
|
226
|
+
const cookieCsrf = this.csrfManager.extractFromCookies(this.cookieStore.getAll());
|
|
227
|
+
if (cookieCsrf) {
|
|
228
|
+
this.csrfManager.cache(cookieCsrf);
|
|
229
|
+
this.logger?.debug("Session: CSRF token cached from cookies");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Get headers for next request
|
|
235
|
+
* @param method HTTP method
|
|
236
|
+
* @param url Optional URL for ETag lookup (for PUT/PATCH with optimistic locking)
|
|
237
|
+
*/
|
|
238
|
+
getRequestHeaders(method, url) {
|
|
239
|
+
const headers = {};
|
|
240
|
+
if (this.securitySessionActive) headers["x-sap-security-session"] = "use";
|
|
241
|
+
const cookieHeader = this.cookieStore.getCookieHeader();
|
|
242
|
+
if (cookieHeader) headers.Cookie = cookieHeader;
|
|
243
|
+
if ([
|
|
244
|
+
"POST",
|
|
245
|
+
"PUT",
|
|
246
|
+
"DELETE",
|
|
247
|
+
"PATCH"
|
|
248
|
+
].includes(method.toUpperCase())) {
|
|
249
|
+
const cachedToken = this.csrfManager.getCached();
|
|
250
|
+
if (cachedToken) headers["x-csrf-token"] = cachedToken;
|
|
251
|
+
else headers["x-csrf-token"] = "Fetch";
|
|
252
|
+
}
|
|
253
|
+
if (["PUT", "PATCH"].includes(method.toUpperCase()) && url) {
|
|
254
|
+
const etag = this.etagManager.get(url);
|
|
255
|
+
if (etag) {
|
|
256
|
+
headers["If-Match"] = etag;
|
|
257
|
+
this.logger?.debug(`Session: Using cached ETag for If-Match: ${etag}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return headers;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Get cached ETag for a URL
|
|
264
|
+
*/
|
|
265
|
+
getETag(url) {
|
|
266
|
+
return this.etagManager.get(url);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Clear cached ETag for a URL
|
|
270
|
+
*/
|
|
271
|
+
clearETag(url) {
|
|
272
|
+
this.etagManager.clear(url);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Reject session paths that could send cleanup requests off-target.
|
|
276
|
+
* Only relative paths under the ADT sessions endpoint (or same-origin
|
|
277
|
+
* absolute URLs with that path) are accepted.
|
|
278
|
+
*/
|
|
279
|
+
isTrustedSessionPath({ baseUrl, sessionPath }) {
|
|
280
|
+
try {
|
|
281
|
+
const parsed = new URL(sessionPath, baseUrl);
|
|
282
|
+
const base = new URL(baseUrl);
|
|
283
|
+
return parsed.origin === base.origin && parsed.pathname.startsWith(SessionManager.SESSION_PATH_PREFIX);
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
extractTrustedSessionPath({ baseUrl, body }) {
|
|
289
|
+
const sessionPath = body.match(/href="([^"]*\/sessions\/[^"]*)"/)?.[1];
|
|
290
|
+
if (!sessionPath || !this.isTrustedSessionPath({
|
|
291
|
+
baseUrl,
|
|
292
|
+
sessionPath
|
|
293
|
+
})) {
|
|
294
|
+
this.logger?.warn("Session: Created security session returned an untrusted path");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
return sessionPath;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Initialize CSRF token using the Eclipse ADT security session flow:
|
|
301
|
+
*
|
|
302
|
+
* 1. GET /sessions + x-sap-security-session: create → create security session
|
|
303
|
+
* 2. GET /sessions + x-sap-security-session: use + x-csrf-token: Fetch → get CSRF token
|
|
304
|
+
* 3. DELETE /sessions/<id> + x-sap-security-session: use + x-csrf-token → destroy session
|
|
305
|
+
*
|
|
306
|
+
* The CSRF token survives the session deletion and remains valid for
|
|
307
|
+
* all subsequent lock/unlock operations. Deleting the session frees
|
|
308
|
+
* the slot — SAP allows only one security session per user.
|
|
309
|
+
*/
|
|
310
|
+
resolveInitializeCsrfArgs(...args) {
|
|
311
|
+
if (args.length === 1) {
|
|
312
|
+
const first = args[0];
|
|
313
|
+
if (first !== null) {
|
|
314
|
+
if (typeof first === "object") return first;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
authHeader: typeof args[0] === "string" ? args[0] : void 0,
|
|
319
|
+
client: typeof args[1] === "string" ? args[1] : void 0,
|
|
320
|
+
language: typeof args[2] === "string" ? args[2] : void 0,
|
|
321
|
+
signal: args[3] instanceof AbortSignal ? args[3] : void 0
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async initializeCsrf(baseUrl, ...args) {
|
|
325
|
+
const { authHeader, client, language, signal } = this.resolveInitializeCsrfArgs(...args);
|
|
326
|
+
signal?.throwIfAborted();
|
|
327
|
+
const sessionsUrl = this.buildSessionsUrl(baseUrl, client, language);
|
|
328
|
+
let sessionPath;
|
|
329
|
+
try {
|
|
330
|
+
sessionPath = await this.createSecuritySession(baseUrl, sessionsUrl, authHeader, signal);
|
|
331
|
+
if (!sessionPath) return false;
|
|
332
|
+
if (!await this.acquireCsrfToken(sessionsUrl, authHeader, signal)) {
|
|
333
|
+
this.deleteSecuritySessionWithFallback({
|
|
334
|
+
sessionPath,
|
|
335
|
+
baseUrl,
|
|
336
|
+
authHeader,
|
|
337
|
+
client,
|
|
338
|
+
language
|
|
339
|
+
}).catch(() => void 0);
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
const acquiredCsrfToken = this.csrfManager.getCached();
|
|
343
|
+
if (acquiredCsrfToken) await this.deleteSecuritySession({
|
|
344
|
+
sessionPath,
|
|
345
|
+
csrfToken: acquiredCsrfToken,
|
|
346
|
+
baseUrl,
|
|
347
|
+
authHeader,
|
|
348
|
+
client,
|
|
349
|
+
signal
|
|
350
|
+
});
|
|
351
|
+
signal?.throwIfAborted();
|
|
352
|
+
return true;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return this.handleCsrfInitializationError(error, {
|
|
355
|
+
baseUrl,
|
|
356
|
+
authHeader,
|
|
357
|
+
client,
|
|
358
|
+
language,
|
|
359
|
+
sessionPath,
|
|
360
|
+
signal
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
buildSessionsUrl(baseUrl, client, language) {
|
|
365
|
+
const url = new URL("/sap/bc/adt/core/http/sessions", baseUrl);
|
|
366
|
+
if (client) url.searchParams.append("sap-client", client);
|
|
367
|
+
if (language) url.searchParams.append("sap-language", language);
|
|
368
|
+
return url;
|
|
369
|
+
}
|
|
370
|
+
buildSessionHeaders(authHeader) {
|
|
371
|
+
const headers = {
|
|
372
|
+
Accept: "application/vnd.sap.adt.core.http.session.v3+xml",
|
|
373
|
+
"X-sap-adt-sessiontype": "stateful"
|
|
374
|
+
};
|
|
375
|
+
if (authHeader) headers.Authorization = authHeader;
|
|
376
|
+
const cookie = this.cookieStore.getCookieHeader();
|
|
377
|
+
if (cookie) headers.Cookie = cookie;
|
|
378
|
+
return headers;
|
|
379
|
+
}
|
|
380
|
+
async createSecuritySession(baseUrl, sessionsUrl, authHeader, signal) {
|
|
381
|
+
this.logger?.debug("Session: Creating security session");
|
|
382
|
+
const response = await this.fetchFn(sessionsUrl, {
|
|
383
|
+
method: "GET",
|
|
384
|
+
headers: {
|
|
385
|
+
...this.buildSessionHeaders(authHeader),
|
|
386
|
+
"x-sap-security-session": "create"
|
|
387
|
+
},
|
|
388
|
+
signal
|
|
389
|
+
});
|
|
390
|
+
signal?.throwIfAborted();
|
|
391
|
+
if (!response.ok) {
|
|
392
|
+
this.logger?.warn(`Session: Security session creation failed with status ${response.status}`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
this.processResponse(response);
|
|
396
|
+
const body = await response.text();
|
|
397
|
+
signal?.throwIfAborted();
|
|
398
|
+
return this.extractTrustedSessionPath({
|
|
399
|
+
baseUrl,
|
|
400
|
+
body
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
async acquireCsrfToken(sessionsUrl, authHeader, signal) {
|
|
404
|
+
this.logger?.debug("Session: Fetching CSRF token");
|
|
405
|
+
const response = await this.fetchFn(sessionsUrl, {
|
|
406
|
+
method: "GET",
|
|
407
|
+
headers: {
|
|
408
|
+
...this.buildSessionHeaders(authHeader),
|
|
409
|
+
"x-sap-security-session": "use",
|
|
410
|
+
"x-csrf-token": "Fetch"
|
|
411
|
+
},
|
|
412
|
+
signal
|
|
413
|
+
});
|
|
414
|
+
signal?.throwIfAborted();
|
|
415
|
+
if (!response.ok) {
|
|
416
|
+
this.logger?.warn(`Session: CSRF fetch failed with status ${response.status}`);
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
this.processResponse(response);
|
|
420
|
+
if (!this.csrfManager.hasCached()) {
|
|
421
|
+
this.logger?.warn("Session: CSRF fetch succeeded but no token found in response");
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
this.securitySessionActive = true;
|
|
425
|
+
this.logger?.debug("Session: CSRF token acquired");
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
async deleteSecuritySession({ sessionPath, csrfToken, baseUrl, authHeader, client, signal }) {
|
|
429
|
+
if (!this.isTrustedSessionPath({
|
|
430
|
+
baseUrl,
|
|
431
|
+
sessionPath
|
|
432
|
+
})) {
|
|
433
|
+
this.logger?.warn("Session: Refusing to delete security session with untrusted path");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const deleteUrl = new URL(sessionPath, baseUrl);
|
|
437
|
+
if (client) deleteUrl.searchParams.append("sap-client", client);
|
|
438
|
+
try {
|
|
439
|
+
this.logger?.debug(`Session: Deleting security session ${sessionPath}`);
|
|
440
|
+
await this.fetchFn(deleteUrl, {
|
|
441
|
+
method: "DELETE",
|
|
442
|
+
headers: {
|
|
443
|
+
...this.buildSessionHeaders(authHeader),
|
|
444
|
+
"x-sap-security-session": "use",
|
|
445
|
+
"x-csrf-token": csrfToken
|
|
446
|
+
},
|
|
447
|
+
signal: signal ?? AbortSignal.timeout(5e3)
|
|
448
|
+
});
|
|
449
|
+
this.logger?.debug("Session: Security session deleted");
|
|
450
|
+
} catch {
|
|
451
|
+
this.logger?.debug("Session: Failed to delete security session (will expire)");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async fetchSecuritySessionCsrfToken({ baseUrl, authHeader, client, language, signal }) {
|
|
455
|
+
const sessionsUrl = this.buildSessionsUrl(baseUrl, client, language);
|
|
456
|
+
try {
|
|
457
|
+
this.logger?.debug("Session: Fetching CSRF token for security session cleanup");
|
|
458
|
+
const response = await this.fetchFn(sessionsUrl, {
|
|
459
|
+
method: "GET",
|
|
460
|
+
headers: {
|
|
461
|
+
...this.buildSessionHeaders(authHeader),
|
|
462
|
+
"x-sap-security-session": "use",
|
|
463
|
+
"x-csrf-token": "Fetch"
|
|
464
|
+
},
|
|
465
|
+
signal: signal ?? AbortSignal.timeout(5e3)
|
|
466
|
+
});
|
|
467
|
+
if (!response.ok) {
|
|
468
|
+
this.logger?.debug(`Session: Cleanup CSRF fetch failed with status ${response.status}`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const setCookieHeader = response.headers.get("set-cookie");
|
|
472
|
+
if (setCookieHeader) this.cookieStore.parseCookies(setCookieHeader);
|
|
473
|
+
return this.csrfManager.extractFromHeader(response.headers.get("x-csrf-token"));
|
|
474
|
+
} catch {
|
|
475
|
+
this.logger?.debug("Session: Failed to fetch cleanup CSRF token");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
async deleteSecuritySessionWithFallback({ sessionPath, baseUrl, authHeader, client, language, signal }) {
|
|
480
|
+
const token = await this.fetchSecuritySessionCsrfToken({
|
|
481
|
+
baseUrl,
|
|
482
|
+
authHeader,
|
|
483
|
+
client,
|
|
484
|
+
language,
|
|
485
|
+
signal
|
|
486
|
+
});
|
|
487
|
+
if (token) await this.deleteSecuritySession({
|
|
488
|
+
sessionPath,
|
|
489
|
+
csrfToken: token,
|
|
490
|
+
baseUrl,
|
|
491
|
+
authHeader,
|
|
492
|
+
client,
|
|
493
|
+
signal
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
handleCsrfInitializationError(error, { baseUrl, authHeader, client, language, sessionPath, signal }) {
|
|
497
|
+
if (sessionPath) this.deleteSecuritySessionWithFallback({
|
|
498
|
+
sessionPath,
|
|
499
|
+
baseUrl,
|
|
500
|
+
authHeader,
|
|
501
|
+
client,
|
|
502
|
+
language
|
|
503
|
+
}).catch(() => void 0);
|
|
504
|
+
if (signal?.aborted) throw error;
|
|
505
|
+
this.logger?.error(`Session: CSRF initialization error: ${error instanceof Error ? error.message : String(error)}`);
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Clear all session state (cookies and CSRF)
|
|
510
|
+
*/
|
|
511
|
+
clear() {
|
|
512
|
+
this.cookieStore.clear();
|
|
513
|
+
this.csrfManager.clear();
|
|
514
|
+
this.securitySessionActive = false;
|
|
515
|
+
this.logger?.debug("Session: Cleared all session state (cookies and CSRF)");
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Check if we have a cached CSRF token
|
|
519
|
+
*/
|
|
520
|
+
hasCsrfToken() {
|
|
521
|
+
return this.csrfManager.hasCached();
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Get Cookie header value for requests
|
|
525
|
+
*/
|
|
526
|
+
getCookieHeader() {
|
|
527
|
+
return this.cookieStore.getCookieHeader();
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Get session type header value
|
|
531
|
+
* SAP ADT requires 'stateful' for operations that need session persistence
|
|
532
|
+
*/
|
|
533
|
+
getSessionTypeHeader() {
|
|
534
|
+
return "stateful";
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Inject a pre-existing cookie (e.g., from SAML authentication)
|
|
538
|
+
* @param cookieString Cookie string in "name=value" format
|
|
539
|
+
*/
|
|
540
|
+
injectCookie(cookieString) {
|
|
541
|
+
this.cookieStore.injectCookie(cookieString);
|
|
542
|
+
this.logger?.debug(`Session: Injected cookie from external source`);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
//#endregion
|
|
547
|
+
//#region src/errors.ts
|
|
548
|
+
/**
|
|
549
|
+
* ADT Error Handling
|
|
550
|
+
*
|
|
551
|
+
* SAP ADT returns errors in a standard XML format defined by exception.xsd:
|
|
552
|
+
* <exc:exception xmlns:exc="http://www.sap.com/abapxml/types/communicationframework">
|
|
553
|
+
* <namespace id="..."/>
|
|
554
|
+
* <type id="..."/>
|
|
555
|
+
* <message lang="EN">Human readable error message</message>
|
|
556
|
+
* <localizedMessage lang="EN">Localized message</localizedMessage>
|
|
557
|
+
* <properties>
|
|
558
|
+
* <entry key="key1">value1</entry>
|
|
559
|
+
* </properties>
|
|
560
|
+
* </exc:exception>
|
|
561
|
+
*
|
|
562
|
+
* Uses the generated exception schema from @abapify/adt-schemas for type-safe parsing.
|
|
563
|
+
*/
|
|
564
|
+
/**
|
|
565
|
+
* Custom error class for ADT exceptions
|
|
566
|
+
*
|
|
567
|
+
* Provides structured access to SAP ADT error details.
|
|
568
|
+
*/
|
|
569
|
+
var AdtError = class AdtError extends Error {
|
|
570
|
+
/** HTTP status code */
|
|
571
|
+
status;
|
|
572
|
+
/** HTTP status text */
|
|
573
|
+
statusText;
|
|
574
|
+
/** Parsed ADT exception data (if available) */
|
|
575
|
+
exception;
|
|
576
|
+
/** Raw response body */
|
|
577
|
+
rawBody;
|
|
578
|
+
/** Request URL */
|
|
579
|
+
url;
|
|
580
|
+
/** HTTP method */
|
|
581
|
+
method;
|
|
582
|
+
constructor(options) {
|
|
583
|
+
const baseMsg = `HTTP ${options.status}: ${options.statusText}`;
|
|
584
|
+
const detailMsg = options.exception?.message || options.exception?.localizedMessage;
|
|
585
|
+
const fullMsg = detailMsg ? `${baseMsg} - ${detailMsg}` : baseMsg;
|
|
586
|
+
super(fullMsg);
|
|
587
|
+
this.name = "AdtError";
|
|
588
|
+
this.status = options.status;
|
|
589
|
+
this.statusText = options.statusText;
|
|
590
|
+
this.url = options.url;
|
|
591
|
+
this.method = options.method;
|
|
592
|
+
this.rawBody = options.rawBody;
|
|
593
|
+
this.exception = options.exception;
|
|
594
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, AdtError);
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Get the most relevant error message
|
|
598
|
+
*/
|
|
599
|
+
get detailMessage() {
|
|
600
|
+
return this.exception?.localizedMessage || this.exception?.message || this.statusText;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Check if this is a specific error type
|
|
604
|
+
*/
|
|
605
|
+
isType(type) {
|
|
606
|
+
return this.exception?.type === type;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Check if this is from a specific namespace
|
|
610
|
+
*/
|
|
611
|
+
isNamespace(namespace) {
|
|
612
|
+
return this.exception?.namespace === namespace;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Get a property value from the exception
|
|
616
|
+
*/
|
|
617
|
+
getProperty(key) {
|
|
618
|
+
return this.exception?.properties?.[key];
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
/**
|
|
622
|
+
* Parse ADT exception XML into structured data
|
|
623
|
+
*
|
|
624
|
+
* Uses the generated exception schema from @abapify/adt-schemas for type-safe parsing.
|
|
625
|
+
*/
|
|
626
|
+
function parseAdtException(xml) {
|
|
627
|
+
if (!xml.includes("exception") || !/xmlns(?::[\w-]+)?="http:\/\/www\.sap\.com\/abapxml\/types\/communicationframework"/.test(xml)) return;
|
|
628
|
+
try {
|
|
629
|
+
const exc = exception.parse(xml).exception;
|
|
630
|
+
if (!exc) return;
|
|
631
|
+
const result = {};
|
|
632
|
+
if (exc.namespace?.id) result.namespace = exc.namespace.id;
|
|
633
|
+
if (exc.type?.id) result.type = exc.type.id;
|
|
634
|
+
if (exc.message?.$value) result.message = exc.message.$value;
|
|
635
|
+
if (exc.localizedMessage?.$value) result.localizedMessage = exc.localizedMessage.$value;
|
|
636
|
+
if (exc.properties?.entry) {
|
|
637
|
+
result.properties = {};
|
|
638
|
+
const entries = Array.isArray(exc.properties.entry) ? exc.properties.entry : [exc.properties.entry];
|
|
639
|
+
for (const entry of entries) if (entry.key && entry.$value) result.properties[entry.key] = entry.$value;
|
|
640
|
+
}
|
|
641
|
+
if (!result.namespace && !result.type && !result.message && !result.localizedMessage) return;
|
|
642
|
+
return result;
|
|
643
|
+
} catch {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Create an AdtError from HTTP response details
|
|
649
|
+
*/
|
|
650
|
+
function createAdtError(status, statusText, url, method, rawBody) {
|
|
651
|
+
const exception = parseAdtException(rawBody);
|
|
652
|
+
return new AdtError({
|
|
653
|
+
status,
|
|
654
|
+
statusText,
|
|
655
|
+
url,
|
|
656
|
+
method,
|
|
657
|
+
rawBody,
|
|
658
|
+
exception
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/cancellation.ts
|
|
664
|
+
const adtAbortSignal = new AsyncLocalStorage();
|
|
665
|
+
/**
|
|
666
|
+
* Runs an ADT operation with an execution-scoped abort signal. Async local
|
|
667
|
+
* storage keeps concurrent clients and invocations isolated without mutating
|
|
668
|
+
* shared client state.
|
|
669
|
+
*/
|
|
670
|
+
async function runWithAdtAbortSignal(signal, operation) {
|
|
671
|
+
signal.throwIfAborted();
|
|
672
|
+
return await adtAbortSignal.run(signal, operation);
|
|
673
|
+
}
|
|
674
|
+
function activeAdtAbortSignal() {
|
|
675
|
+
return adtAbortSignal.getStore();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
//#endregion
|
|
679
|
+
//#region src/adapter.ts
|
|
680
|
+
/**
|
|
681
|
+
* A response exceeded an explicit byte cap before its body was retained.
|
|
682
|
+
* Deliberately carries size metadata only, never response text.
|
|
683
|
+
*/
|
|
684
|
+
var AdtResponseTooLargeError = class extends Error {
|
|
685
|
+
maxBytes;
|
|
686
|
+
receivedBytes;
|
|
687
|
+
code = "ADT_RESPONSE_TOO_LARGE";
|
|
688
|
+
constructor(maxBytes, receivedBytes) {
|
|
689
|
+
super(`ADT response exceeds the ${maxBytes}-byte limit.`);
|
|
690
|
+
this.maxBytes = maxBytes;
|
|
691
|
+
this.receivedBytes = receivedBytes;
|
|
692
|
+
this.name = "AdtResponseTooLargeError";
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
function contentLengthExceedsLimit(contentLength, maxBytes) {
|
|
696
|
+
if (!contentLength || !/^\d+$/.test(contentLength)) return void 0;
|
|
697
|
+
const parsed = Number(contentLength);
|
|
698
|
+
return Number.isSafeInteger(parsed) && parsed > maxBytes ? parsed : void 0;
|
|
699
|
+
}
|
|
700
|
+
async function abortResponse(response, abortController) {
|
|
701
|
+
abortController.abort();
|
|
702
|
+
try {
|
|
703
|
+
await response.body?.cancel();
|
|
704
|
+
} catch {}
|
|
705
|
+
}
|
|
706
|
+
async function readResponseTextBounded(response, maxBytes, abortController) {
|
|
707
|
+
const declaredBytes = contentLengthExceedsLimit(response.headers.get("content-length"), maxBytes);
|
|
708
|
+
if (declaredBytes !== void 0) {
|
|
709
|
+
await abortResponse(response, abortController);
|
|
710
|
+
throw new AdtResponseTooLargeError(maxBytes, declaredBytes);
|
|
711
|
+
}
|
|
712
|
+
if (!response.body) return "";
|
|
713
|
+
const reader = response.body.getReader();
|
|
714
|
+
const chunks = [];
|
|
715
|
+
let receivedBytes = 0;
|
|
716
|
+
try {
|
|
717
|
+
while (true) {
|
|
718
|
+
const { done, value } = await reader.read();
|
|
719
|
+
if (done) break;
|
|
720
|
+
if (!value) continue;
|
|
721
|
+
const nextReceivedBytes = receivedBytes + value.byteLength;
|
|
722
|
+
if (nextReceivedBytes > maxBytes) {
|
|
723
|
+
abortController.abort();
|
|
724
|
+
try {
|
|
725
|
+
await reader.cancel();
|
|
726
|
+
} catch {}
|
|
727
|
+
throw new AdtResponseTooLargeError(maxBytes, nextReceivedBytes);
|
|
728
|
+
}
|
|
729
|
+
chunks.push(value);
|
|
730
|
+
receivedBytes = nextReceivedBytes;
|
|
731
|
+
}
|
|
732
|
+
} finally {
|
|
733
|
+
reader.releaseLock();
|
|
734
|
+
}
|
|
735
|
+
const combined = new Uint8Array(receivedBytes);
|
|
736
|
+
let offset = 0;
|
|
737
|
+
for (const chunk of chunks) {
|
|
738
|
+
combined.set(chunk, offset);
|
|
739
|
+
offset += chunk.byteLength;
|
|
740
|
+
}
|
|
741
|
+
return new TextDecoder().decode(combined);
|
|
742
|
+
}
|
|
743
|
+
function executionAbortController() {
|
|
744
|
+
const abortController = new AbortController();
|
|
745
|
+
const executionSignal = activeAdtAbortSignal();
|
|
746
|
+
if (!executionSignal) return {
|
|
747
|
+
abortController,
|
|
748
|
+
dispose: () => void 0
|
|
749
|
+
};
|
|
750
|
+
const abortFromExecution = () => abortController.abort(executionSignal.reason);
|
|
751
|
+
if (executionSignal.aborted) abortFromExecution();
|
|
752
|
+
else executionSignal.addEventListener("abort", abortFromExecution, { once: true });
|
|
753
|
+
return {
|
|
754
|
+
abortController,
|
|
755
|
+
dispose: () => executionSignal.removeEventListener("abort", abortFromExecution)
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
function createAdtAdapter(config) {
|
|
759
|
+
const { baseUrl, username, password, cookieHeader, authorizationHeader, client, language, logger, plugins = [], onSessionExpired, headersTimeoutMs } = config;
|
|
760
|
+
const dispatcher = headersTimeoutMs === void 0 ? void 0 : new Agent({ headersTimeout: headersTimeoutMs });
|
|
761
|
+
/** Merge the optional Undici dispatcher into a fetch RequestInit. */
|
|
762
|
+
function withDispatcher(init) {
|
|
763
|
+
return dispatcher ? {
|
|
764
|
+
...init,
|
|
765
|
+
dispatcher
|
|
766
|
+
} : init;
|
|
767
|
+
}
|
|
768
|
+
/** Keep a configured Agent paired with the Undici fetch ABI that created it. */
|
|
769
|
+
async function requestFetch(input, init) {
|
|
770
|
+
if (!dispatcher) return globalThis.fetch(input, init);
|
|
771
|
+
return await fetch(input, init);
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Route a session-manager request through the same paired Undici
|
|
775
|
+
* fetch/dispatcher used for the final adapter request, so the
|
|
776
|
+
* configured headersTimeout applies to the entire write flow
|
|
777
|
+
* (CSRF handshake included), not just the last hop.
|
|
778
|
+
*/
|
|
779
|
+
async function sessionFetch(input, init) {
|
|
780
|
+
return requestFetch(input.toString(), withDispatcher(init ?? {}));
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Resolve `options.url` against the configured `baseUrl` and reject
|
|
784
|
+
* absolute URLs that would send credentials to a different origin.
|
|
785
|
+
*/
|
|
786
|
+
function resolveAdtUrl(requestUrl) {
|
|
787
|
+
const resolved = new URL(requestUrl, baseUrl);
|
|
788
|
+
const baseOrigin = new URL(baseUrl).origin;
|
|
789
|
+
if (resolved.origin !== baseOrigin) throw new RangeError(`Refusing to fetch ${resolved.origin} — ADT requests must target the configured base origin ${baseOrigin}`);
|
|
790
|
+
return resolved;
|
|
791
|
+
}
|
|
792
|
+
const isSamlAuth = !!cookieHeader;
|
|
793
|
+
const basicCredentials = `${username}:${password}`;
|
|
794
|
+
const authHeader = authorizationHeader ?? (isSamlAuth ? void 0 : `Basic ${Buffer.from(basicCredentials).toString("base64")}`);
|
|
795
|
+
const sessionManager = new SessionManager(logger, sessionFetch);
|
|
796
|
+
if (cookieHeader) sessionManager.injectCookie(cookieHeader);
|
|
797
|
+
return {
|
|
798
|
+
async request(options) {
|
|
799
|
+
const executionSignal = activeAdtAbortSignal();
|
|
800
|
+
executionSignal?.throwIfAborted();
|
|
801
|
+
logger?.debug("=== ADAPTER REQUEST START ===");
|
|
802
|
+
logger?.debug("options.url:", options.url);
|
|
803
|
+
logger?.debug("options.method:", options.method);
|
|
804
|
+
logger?.debug("options.body:", options.body ? "present" : "undefined");
|
|
805
|
+
logger?.debug("options.bodySchema:", options.bodySchema ? "present" : "undefined");
|
|
806
|
+
const url = resolveAdtUrl(options.url);
|
|
807
|
+
if (options.query) Object.entries(options.query).forEach(([key, value]) => {
|
|
808
|
+
(Array.isArray(value) ? value : [value]).forEach((entry) => {
|
|
809
|
+
if (entry !== void 0) url.searchParams.append(key, String(entry));
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
if (client) url.searchParams.append("sap-client", client);
|
|
813
|
+
if (language) url.searchParams.append("sap-language", language);
|
|
814
|
+
if ([
|
|
815
|
+
"POST",
|
|
816
|
+
"PUT",
|
|
817
|
+
"DELETE",
|
|
818
|
+
"PATCH"
|
|
819
|
+
].includes(options.method.toUpperCase()) && !sessionManager.hasCsrfToken()) {
|
|
820
|
+
logger?.debug("Adapter: Initializing CSRF token before write operation");
|
|
821
|
+
await sessionManager.initializeCsrf(baseUrl, {
|
|
822
|
+
authHeader,
|
|
823
|
+
client,
|
|
824
|
+
language,
|
|
825
|
+
signal: executionSignal
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
const headers = {
|
|
829
|
+
"X-sap-adt-sessiontype": sessionManager.getSessionTypeHeader(),
|
|
830
|
+
...sessionManager.getRequestHeaders(options.method, url.pathname),
|
|
831
|
+
...options.headers
|
|
832
|
+
};
|
|
833
|
+
if (authHeader) headers.Authorization = authHeader;
|
|
834
|
+
let bodySerializableSchema;
|
|
835
|
+
if (options.bodySchema && typeof options.bodySchema === "object") {
|
|
836
|
+
if ("build" in options.bodySchema && typeof options.bodySchema.build === "function") bodySerializableSchema = options.bodySchema;
|
|
837
|
+
}
|
|
838
|
+
let serializableSchema;
|
|
839
|
+
if (options.responses) {
|
|
840
|
+
const schema200 = options.responses[200];
|
|
841
|
+
logger?.debug("Schema detection:", {
|
|
842
|
+
hasSchema: !!schema200,
|
|
843
|
+
type: typeof schema200,
|
|
844
|
+
keys: schema200 && typeof schema200 === "object" ? Object.keys(schema200) : []
|
|
845
|
+
});
|
|
846
|
+
if (schema200 && typeof schema200 === "object" && "parse" in schema200 && typeof schema200.parse === "function") serializableSchema = schema200;
|
|
847
|
+
}
|
|
848
|
+
let requestBody;
|
|
849
|
+
const body = options.body;
|
|
850
|
+
logger?.debug("Request URL: " + options.url);
|
|
851
|
+
logger?.debug("Request method: " + options.method);
|
|
852
|
+
logger?.debug("options.body: " + (options.body ? JSON.stringify(options.body).substring(0, 500) : "UNDEFINED"));
|
|
853
|
+
logger?.debug("options.bodySchema: " + (options.bodySchema ? "present" : "undefined"));
|
|
854
|
+
logger?.debug("bodySerializableSchema: " + (bodySerializableSchema ? "present" : "undefined"));
|
|
855
|
+
logger?.debug("Body type: " + typeof body);
|
|
856
|
+
logger?.debug("Body value: " + (body ? JSON.stringify(body).substring(0, 500) : "UNDEFINED/NULL"));
|
|
857
|
+
if (body !== void 0 && body !== null) {
|
|
858
|
+
if (typeof body === "string") {
|
|
859
|
+
requestBody = body;
|
|
860
|
+
logger?.debug("Body: using raw string");
|
|
861
|
+
logger?.debug("Body content (first 200 chars):", requestBody.substring(0, 200));
|
|
862
|
+
} else if (bodySerializableSchema) try {
|
|
863
|
+
logger?.debug("Calling bodySerializableSchema.build with body: " + JSON.stringify(body).substring(0, 300));
|
|
864
|
+
requestBody = bodySerializableSchema.build(body);
|
|
865
|
+
logger?.debug("Body: serialized using Serializable.build()");
|
|
866
|
+
logger?.debug("Body output type: " + typeof requestBody);
|
|
867
|
+
logger?.debug("Body output length: " + requestBody?.length);
|
|
868
|
+
logger?.debug("Body content (first 500 chars): " + requestBody?.substring(0, 500));
|
|
869
|
+
} catch (buildError) {
|
|
870
|
+
logger?.error("Schema build() threw error: " + (buildError instanceof Error ? buildError.message : String(buildError)));
|
|
871
|
+
logger?.error("Stack: " + (buildError instanceof Error ? buildError.stack : "N/A"));
|
|
872
|
+
throw buildError;
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
requestBody = JSON.stringify(body);
|
|
876
|
+
logger?.debug("Body: serialized as JSON");
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
logger?.debug(`HTTP ${options.method} ${url.toString()}`);
|
|
880
|
+
{
|
|
881
|
+
const headerNames = Object.keys(headers).sort();
|
|
882
|
+
logger?.debug("Request headers (names only): " + JSON.stringify(headerNames));
|
|
883
|
+
}
|
|
884
|
+
const response = await requestFetch(url.toString(), withDispatcher({
|
|
885
|
+
method: options.method,
|
|
886
|
+
headers,
|
|
887
|
+
body: requestBody,
|
|
888
|
+
signal: executionSignal
|
|
889
|
+
}));
|
|
890
|
+
sessionManager.processResponse(response, url.pathname);
|
|
891
|
+
logger?.debug(`Response: ${response.status} ${response.statusText}`);
|
|
892
|
+
logger?.debug("Response headers: " + JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
|
|
893
|
+
if (!response.ok) {
|
|
894
|
+
const errorBody = await response.text();
|
|
895
|
+
const adtError = createAdtError(response.status, response.statusText, url.toString(), options.method, errorBody);
|
|
896
|
+
if (response.status === 412) {
|
|
897
|
+
const sentETag = headers["If-Match"] ?? "(none)";
|
|
898
|
+
const diag = `[If-Match: ${sentETag}, URL: ${url.pathname}]`;
|
|
899
|
+
logger?.error(`ETag mismatch: ${options.method} ${url.pathname} sent If-Match: ${sentETag}`);
|
|
900
|
+
Object.defineProperty(adtError, "message", {
|
|
901
|
+
value: `${adtError.message} ${diag}`,
|
|
902
|
+
writable: true,
|
|
903
|
+
configurable: true
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
logger?.error(`Request failed - ${adtError.message} (${options.method} ${url.toString()})`);
|
|
907
|
+
if (adtError.exception) logger?.error(`ADT Exception: namespace=${adtError.exception.namespace}, type=${adtError.exception.type}`);
|
|
908
|
+
if (errorBody && !adtError.exception) logger?.error(`Response body: ${errorBody}`);
|
|
909
|
+
if (response.status === 403) {
|
|
910
|
+
if (!(adtError.exception?.type === "ExceptionResourceNoAccess" && (adtError.message.includes("currently editing") || adtError.message.includes("is being edited")))) {
|
|
911
|
+
sessionManager.clear();
|
|
912
|
+
logger?.warn("Session cleared due to 403 Forbidden response");
|
|
913
|
+
} else logger?.warn("Lock conflict (403) — session preserved (object locked by another session)");
|
|
914
|
+
}
|
|
915
|
+
throw adtError;
|
|
916
|
+
}
|
|
917
|
+
const contentType = response.headers.get("content-type") || "";
|
|
918
|
+
const rawText = await response.text();
|
|
919
|
+
if (contentType.includes("text/html") && rawText.includes("SAMLRequest")) {
|
|
920
|
+
sessionManager.clear();
|
|
921
|
+
logger?.warn("SAML redirect detected - session expired");
|
|
922
|
+
if (onSessionExpired) {
|
|
923
|
+
logger?.info("Attempting automatic re-authentication...");
|
|
924
|
+
try {
|
|
925
|
+
const newCookie = await onSessionExpired();
|
|
926
|
+
sessionManager.injectCookie(newCookie);
|
|
927
|
+
logger?.info("Re-authentication successful, retrying request...");
|
|
928
|
+
return this.request(options);
|
|
929
|
+
} catch (refreshError) {
|
|
930
|
+
logger?.error("Re-authentication failed");
|
|
931
|
+
throw new Error("Session expired - re-authentication failed", { cause: refreshError });
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
throw new Error("Session expired - SAML re-authentication required");
|
|
935
|
+
}
|
|
936
|
+
let data;
|
|
937
|
+
if (contentType.includes("application/json") || contentType.includes("+json")) data = JSON.parse(rawText);
|
|
938
|
+
else if (contentType.includes("text/") || contentType.includes("xml")) {
|
|
939
|
+
if (serializableSchema && contentType.includes("xml")) data = serializableSchema.parse(rawText);
|
|
940
|
+
else data = rawText;
|
|
941
|
+
} else data = rawText;
|
|
942
|
+
if (plugins.length > 0) {
|
|
943
|
+
const context = {
|
|
944
|
+
rawText,
|
|
945
|
+
parsedData: data,
|
|
946
|
+
url: url.toString(),
|
|
947
|
+
method: options.method,
|
|
948
|
+
contentType
|
|
949
|
+
};
|
|
950
|
+
for (const plugin of plugins) {
|
|
951
|
+
const result = await plugin.process(context);
|
|
952
|
+
if (result !== void 0) {
|
|
953
|
+
data = result;
|
|
954
|
+
context.parsedData = result;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return data;
|
|
959
|
+
},
|
|
960
|
+
clearETag(url) {
|
|
961
|
+
sessionManager.clearETag(url);
|
|
962
|
+
},
|
|
963
|
+
async readTextBounded(options, maxBytes) {
|
|
964
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new RangeError("maxBytes must be a non-negative safe integer.");
|
|
965
|
+
const url = resolveAdtUrl(options.url);
|
|
966
|
+
if (client) url.searchParams.append("sap-client", client);
|
|
967
|
+
if (language) url.searchParams.append("sap-language", language);
|
|
968
|
+
const headers = {
|
|
969
|
+
"X-sap-adt-sessiontype": sessionManager.getSessionTypeHeader(),
|
|
970
|
+
...sessionManager.getRequestHeaders("GET", url.pathname),
|
|
971
|
+
...options.headers
|
|
972
|
+
};
|
|
973
|
+
if (authHeader) headers.Authorization = authHeader;
|
|
974
|
+
const { abortController, dispose } = executionAbortController();
|
|
975
|
+
try {
|
|
976
|
+
const response = await requestFetch(url.toString(), withDispatcher({
|
|
977
|
+
method: "GET",
|
|
978
|
+
headers,
|
|
979
|
+
signal: abortController.signal
|
|
980
|
+
}));
|
|
981
|
+
sessionManager.processResponse(response, url.pathname);
|
|
982
|
+
const text = await readResponseTextBounded(response, maxBytes, abortController);
|
|
983
|
+
if (!response.ok) throw createAdtError(response.status, response.statusText, url.toString(), "GET", text);
|
|
984
|
+
return text;
|
|
985
|
+
} finally {
|
|
986
|
+
dispose();
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
//#endregion
|
|
993
|
+
//#region src/services/transports.ts
|
|
994
|
+
/**
|
|
995
|
+
* Transport Service - orchestrates CTS operations
|
|
996
|
+
*/
|
|
997
|
+
var TransportService = class {
|
|
998
|
+
adt;
|
|
999
|
+
constructor(adt) {
|
|
1000
|
+
this.adt = adt;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* List transport requests
|
|
1004
|
+
*/
|
|
1005
|
+
async list(options) {
|
|
1006
|
+
const response = await this.adt.cts.transportrequests.list(options);
|
|
1007
|
+
return this.extractRequests(response).map((req) => this.mapToTransport(req));
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Get a single transport by number
|
|
1011
|
+
*/
|
|
1012
|
+
async get(trkorr) {
|
|
1013
|
+
const data = await this.adt.cts.transportrequests.get(trkorr);
|
|
1014
|
+
const unwrapped = data?.root && typeof data.root === "object" ? data.root : data;
|
|
1015
|
+
const wanted = trkorr.trim().toUpperCase();
|
|
1016
|
+
const request = this.findUnit(unwrapped?.task, wanted) ?? this.findUnit(unwrapped?.request, wanted) ?? unwrapped?.request ?? unwrapped?.task ?? unwrapped;
|
|
1017
|
+
return this.mapToTransport(request);
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Create a new transport request
|
|
1021
|
+
*
|
|
1022
|
+
* Note: Full create implementation requires XML body building.
|
|
1023
|
+
* For now, this is a placeholder that throws a helpful error.
|
|
1024
|
+
* Use ADK's AdkTransportRequest.create() for full functionality.
|
|
1025
|
+
*/
|
|
1026
|
+
async create(_options) {
|
|
1027
|
+
throw new Error("Transport creation via service layer not yet implemented. Use ADK's AdkTransportRequest.create() for now.");
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Delete a transport request
|
|
1031
|
+
*/
|
|
1032
|
+
async delete(trkorr) {
|
|
1033
|
+
await this.adt.cts.transportrequests.delete(trkorr);
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Release a transport request
|
|
1037
|
+
*
|
|
1038
|
+
* Note: Release requires POST with specific action parameter.
|
|
1039
|
+
* This is a placeholder for the full implementation.
|
|
1040
|
+
*/
|
|
1041
|
+
async release(_trkorr) {
|
|
1042
|
+
throw new Error("Transport release via service layer not yet implemented.");
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Find a request/task unit whose number matches the wanted transport id.
|
|
1046
|
+
* Handles both single-object and array shapes from ts-xsd.
|
|
1047
|
+
*/
|
|
1048
|
+
findUnit(node, wanted) {
|
|
1049
|
+
if (!node) return void 0;
|
|
1050
|
+
return (Array.isArray(node) ? node : [node]).find((item) => {
|
|
1051
|
+
const u = item;
|
|
1052
|
+
return String(u?.trkorr ?? u?.number ?? "").toUpperCase() === wanted;
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Extract requests array from various response formats
|
|
1057
|
+
*/
|
|
1058
|
+
extractRequests(response) {
|
|
1059
|
+
if (!response) return [];
|
|
1060
|
+
const data = response;
|
|
1061
|
+
if (Array.isArray(data.request)) return data.request;
|
|
1062
|
+
if (data.request && typeof data.request === "object") return [data.request];
|
|
1063
|
+
if (Array.isArray(response)) return response;
|
|
1064
|
+
if (data.root && typeof data.root === "object") return this.extractRequests(data.root);
|
|
1065
|
+
return [];
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Map raw request data to Transport interface
|
|
1069
|
+
*/
|
|
1070
|
+
mapToTransport(req) {
|
|
1071
|
+
const data = req;
|
|
1072
|
+
return {
|
|
1073
|
+
number: String(data.trkorr ?? data.number ?? ""),
|
|
1074
|
+
desc: data.as4text ?? data.desc,
|
|
1075
|
+
owner: data.as4user ?? data.owner,
|
|
1076
|
+
status: data.trstatus ?? data.status,
|
|
1077
|
+
type: data.trfunction ?? data.type,
|
|
1078
|
+
target: data.tarsystem ?? data.target,
|
|
1079
|
+
parent: data.parent,
|
|
1080
|
+
lastChangedTimestamp: data.lastchanged_timestamp ?? data.lastChangedTimestamp,
|
|
1081
|
+
tasks: this.extractTasks(data)
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Extract tasks from transport data
|
|
1086
|
+
*/
|
|
1087
|
+
extractTasks(data) {
|
|
1088
|
+
const tasks = data.task ?? data.tasks;
|
|
1089
|
+
if (!tasks) return [];
|
|
1090
|
+
return (Array.isArray(tasks) ? tasks : [tasks]).map((task) => {
|
|
1091
|
+
const t = task;
|
|
1092
|
+
return {
|
|
1093
|
+
number: String(t.trkorr ?? t.number ?? ""),
|
|
1094
|
+
owner: t.as4user ?? t.owner,
|
|
1095
|
+
status: t.trstatus ?? t.status,
|
|
1096
|
+
desc: t.as4text ?? t.desc,
|
|
1097
|
+
type: t.trfunction ?? t.type,
|
|
1098
|
+
parent: t.parent,
|
|
1099
|
+
lastChangedTimestamp: t.lastchanged_timestamp ?? t.lastChangedTimestamp
|
|
1100
|
+
};
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
/**
|
|
1105
|
+
* Create transport service instance
|
|
1106
|
+
*/
|
|
1107
|
+
function createTransportService(adt) {
|
|
1108
|
+
return new TransportService(adt);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
//#endregion
|
|
1112
|
+
//#region src/services/users.ts
|
|
1113
|
+
/** Extract entry array from atomFeed parsed response */
|
|
1114
|
+
function extractEntries(data) {
|
|
1115
|
+
const feedData = data.feed;
|
|
1116
|
+
if (!feedData) return [];
|
|
1117
|
+
const rawEntries = feedData.entry;
|
|
1118
|
+
if (!rawEntries) return [];
|
|
1119
|
+
return Array.isArray(rawEntries) ? rawEntries : [rawEntries];
|
|
1120
|
+
}
|
|
1121
|
+
/** Map raw Atom entries to normalized UserInfo */
|
|
1122
|
+
function toUserInfoList(entries) {
|
|
1123
|
+
return entries.map((e) => ({
|
|
1124
|
+
username: e.id ?? "",
|
|
1125
|
+
fullName: e.title,
|
|
1126
|
+
uri: e.link?.[0]?.href
|
|
1127
|
+
}));
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* User Service - orchestrates user lookup operations
|
|
1131
|
+
*/
|
|
1132
|
+
var UserService = class {
|
|
1133
|
+
adt;
|
|
1134
|
+
constructor(adt) {
|
|
1135
|
+
this.adt = adt;
|
|
1136
|
+
}
|
|
1137
|
+
/** Get the currently authenticated user via systeminformation */
|
|
1138
|
+
async getCurrentUser() {
|
|
1139
|
+
const sysInfo = await this.adt.core.http.systeminformation.getSystemInfo();
|
|
1140
|
+
return {
|
|
1141
|
+
userName: sysInfo.userName ?? "",
|
|
1142
|
+
userFullName: sysInfo.userFullName,
|
|
1143
|
+
systemID: sysInfo.systemID ?? "",
|
|
1144
|
+
client: sysInfo.client ?? ""
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
/** Look up a specific user by exact username */
|
|
1148
|
+
async getUserByName(username) {
|
|
1149
|
+
const normalized = username.trim().toUpperCase();
|
|
1150
|
+
if (!normalized) throw new Error("username is required");
|
|
1151
|
+
return toUserInfoList(extractEntries(await this.adt.system.users.get(normalized)));
|
|
1152
|
+
}
|
|
1153
|
+
/** Search users by wildcard query */
|
|
1154
|
+
async searchUsers(query, maxcount = 50) {
|
|
1155
|
+
const normalizedQuery = query.trim();
|
|
1156
|
+
if (!normalizedQuery) throw new Error("query is required");
|
|
1157
|
+
if (!Number.isInteger(maxcount) || maxcount <= 0) throw new Error("maxcount must be a positive integer");
|
|
1158
|
+
return toUserInfoList(extractEntries(await this.adt.system.users.search({
|
|
1159
|
+
querystring: normalizedQuery,
|
|
1160
|
+
maxcount
|
|
1161
|
+
})));
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
function createUserService(adt) {
|
|
1165
|
+
return new UserService(adt);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
//#endregion
|
|
1169
|
+
//#region src/services/source-history.ts
|
|
1170
|
+
var SourceHistoryProtocolError = class extends Error {
|
|
1171
|
+
code;
|
|
1172
|
+
ordinal;
|
|
1173
|
+
constructor(code, message, ordinal) {
|
|
1174
|
+
super(message);
|
|
1175
|
+
this.code = code;
|
|
1176
|
+
this.ordinal = ordinal;
|
|
1177
|
+
this.name = "SourceHistoryProtocolError";
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
/**
|
|
1181
|
+
* An immutable source body crossed its configured byte limit.
|
|
1182
|
+
* This error intentionally does not retain the source URI or any source text.
|
|
1183
|
+
*/
|
|
1184
|
+
var SourceVersionTooLargeError = class extends Error {
|
|
1185
|
+
maxBytes;
|
|
1186
|
+
receivedBytes;
|
|
1187
|
+
code = "SOURCE_VERSION_TOO_LARGE";
|
|
1188
|
+
constructor(maxBytes, receivedBytes) {
|
|
1189
|
+
super(`Source version exceeds the ${maxBytes}-byte limit.`);
|
|
1190
|
+
this.maxBytes = maxBytes;
|
|
1191
|
+
this.receivedBytes = receivedBytes;
|
|
1192
|
+
this.name = "SourceVersionTooLargeError";
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
const ADT_PATH_PREFIX = "/sap/bc/adt/";
|
|
1196
|
+
const TRAVERSAL_SEGMENT = /(?:^|\/)(?:\.{1,2}|%2e(?:%2e)?)(?:\/|$)/i;
|
|
1197
|
+
const TRANSPORT_NUMBER = /^[A-Z0-9]{10}$/;
|
|
1198
|
+
function asOptionalString(value) {
|
|
1199
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1200
|
+
}
|
|
1201
|
+
function asArray(value) {
|
|
1202
|
+
if (value === void 0) return [];
|
|
1203
|
+
return Array.isArray(value) ? value : [value];
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Validate a server-relative ADT URI before it reaches the authenticated
|
|
1207
|
+
* adapter. This deliberately rejects absolute and protocol-relative URLs so a
|
|
1208
|
+
* source-history link cannot redirect credentials to another origin.
|
|
1209
|
+
*/
|
|
1210
|
+
function isUnsafeAdtUri(uri) {
|
|
1211
|
+
if (uri !== uri.trim()) return true;
|
|
1212
|
+
if (!uri.startsWith(ADT_PATH_PREFIX)) return true;
|
|
1213
|
+
if (uri.startsWith("//")) return true;
|
|
1214
|
+
if (uri.includes("#")) return true;
|
|
1215
|
+
if (uri.includes("\\")) return true;
|
|
1216
|
+
return TRAVERSAL_SEGMENT.test(uri);
|
|
1217
|
+
}
|
|
1218
|
+
function assertAdtUri(uri) {
|
|
1219
|
+
if (isUnsafeAdtUri(uri)) throw new SourceHistoryProtocolError("SOURCE_HISTORY_URI_UNSAFE", "Source-history URI must be a server-relative SAP ADT path.");
|
|
1220
|
+
return uri;
|
|
1221
|
+
}
|
|
1222
|
+
function isUnsafeRelativeHref(href) {
|
|
1223
|
+
if (href.startsWith("//")) return true;
|
|
1224
|
+
if (href.includes("\\")) return true;
|
|
1225
|
+
if (href.includes(":")) return true;
|
|
1226
|
+
return TRAVERSAL_SEGMENT.test(href);
|
|
1227
|
+
}
|
|
1228
|
+
function resolveRelativeAdtUri(href, versionsUri) {
|
|
1229
|
+
const validatedBase = assertAdtUri(versionsUri);
|
|
1230
|
+
const collectionBase = validatedBase.endsWith("/") ? validatedBase : `${validatedBase}/`;
|
|
1231
|
+
const resolved = new URL(href, `https://adt.invalid${collectionBase}`);
|
|
1232
|
+
return assertAdtUri(`${resolved.pathname}${resolved.search}${resolved.hash}`);
|
|
1233
|
+
}
|
|
1234
|
+
function resolveAdtUri(href, versionsUri) {
|
|
1235
|
+
if (href.startsWith("/")) return assertAdtUri(href);
|
|
1236
|
+
if (isUnsafeRelativeHref(href)) throw new SourceHistoryProtocolError("SOURCE_HISTORY_URI_UNSAFE", "Source-history link must stay within the SAP ADT origin.");
|
|
1237
|
+
return resolveRelativeAdtUri(href, versionsUri);
|
|
1238
|
+
}
|
|
1239
|
+
function normalizeLinks(value) {
|
|
1240
|
+
return asArray(value).filter((link) => Boolean(link) && typeof link === "object");
|
|
1241
|
+
}
|
|
1242
|
+
function isContentLink(link) {
|
|
1243
|
+
const rel = asOptionalString(link.rel)?.toLowerCase();
|
|
1244
|
+
return Boolean(rel?.endsWith("/content") || rel === "content");
|
|
1245
|
+
}
|
|
1246
|
+
function isPlainTextContentLink(link) {
|
|
1247
|
+
const contentType = asOptionalString(link.type)?.toLowerCase();
|
|
1248
|
+
return Boolean(contentType?.startsWith("text/plain"));
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Some SAP source-version feeds represent the immutable source body with the
|
|
1252
|
+
* standard Atom `content` element rather than an Atom link. Treat it exactly
|
|
1253
|
+
* like a content link, but only when it is explicitly plain text.
|
|
1254
|
+
*/
|
|
1255
|
+
function contentElementAsLink(entry) {
|
|
1256
|
+
const content = entry.content;
|
|
1257
|
+
if (!content) return void 0;
|
|
1258
|
+
if (typeof content !== "object") return void 0;
|
|
1259
|
+
if (Array.isArray(content)) return void 0;
|
|
1260
|
+
const src = asOptionalString(content.src);
|
|
1261
|
+
if (!src) return void 0;
|
|
1262
|
+
const type = asOptionalString(content.type);
|
|
1263
|
+
if (!type) return void 0;
|
|
1264
|
+
if (!type.toLowerCase().startsWith("text/plain")) return void 0;
|
|
1265
|
+
return {
|
|
1266
|
+
href: src,
|
|
1267
|
+
type
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
function isTransportRel(rel) {
|
|
1271
|
+
return rel.endsWith("/transport") || rel.endsWith("/transport/request");
|
|
1272
|
+
}
|
|
1273
|
+
function transportFromTitle(link) {
|
|
1274
|
+
const title = asOptionalString(link.title)?.toUpperCase();
|
|
1275
|
+
if (!title) return void 0;
|
|
1276
|
+
if (TRANSPORT_NUMBER.test(title)) return title;
|
|
1277
|
+
}
|
|
1278
|
+
function transportFromHref(link) {
|
|
1279
|
+
const href = asOptionalString(link.href);
|
|
1280
|
+
if (!href) return void 0;
|
|
1281
|
+
const lastSegment = (href.split(/[?#]/, 1)[0]?.split("/").filter((segment) => segment !== ""))?.pop();
|
|
1282
|
+
if (!lastSegment) return void 0;
|
|
1283
|
+
try {
|
|
1284
|
+
const candidate = decodeURIComponent(lastSegment).toUpperCase();
|
|
1285
|
+
return TRANSPORT_NUMBER.test(candidate) ? candidate : void 0;
|
|
1286
|
+
} catch {
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
function transportFromLink(link) {
|
|
1291
|
+
const rel = asOptionalString(link.rel)?.toLowerCase();
|
|
1292
|
+
if (!rel || !isTransportRel(rel)) return void 0;
|
|
1293
|
+
return transportFromTitle(link) ?? transportFromHref(link);
|
|
1294
|
+
}
|
|
1295
|
+
function authorName(value) {
|
|
1296
|
+
return asOptionalString(asArray(value).find((candidate) => Boolean(candidate) && typeof candidate === "object")?.name);
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Normalize the parsed Atom versions feed while preserving SAP's feed order.
|
|
1300
|
+
* The result contains references and provenance only; source bodies are fetched
|
|
1301
|
+
* separately and are never retained by this function.
|
|
1302
|
+
*/
|
|
1303
|
+
function assertSourceVersionPayload(payload) {
|
|
1304
|
+
if (!payload || typeof payload !== "object") throw new SourceHistoryProtocolError("SOURCE_VERSION_FEED_INVALID", "SAP ADT source-history response is not an Atom feed.");
|
|
1305
|
+
if (!("feed" in payload)) throw new SourceHistoryProtocolError("SOURCE_VERSION_FEED_INVALID", "SAP ADT source-history response is not an Atom feed.");
|
|
1306
|
+
return payload;
|
|
1307
|
+
}
|
|
1308
|
+
function assertSourceVersionFeed(feed) {
|
|
1309
|
+
if (!feed) throw invalidFeedError();
|
|
1310
|
+
if (typeof feed !== "object") throw invalidFeedError();
|
|
1311
|
+
if (Array.isArray(feed)) throw invalidFeedError();
|
|
1312
|
+
return feed;
|
|
1313
|
+
}
|
|
1314
|
+
function invalidFeedError() {
|
|
1315
|
+
return new SourceHistoryProtocolError("SOURCE_VERSION_FEED_INVALID", "SAP ADT source-history feed has an invalid root.");
|
|
1316
|
+
}
|
|
1317
|
+
function readSourceVersionEntries(feed) {
|
|
1318
|
+
if (feed.entry === void 0) return [];
|
|
1319
|
+
const entries = asArray(feed.entry);
|
|
1320
|
+
if (entries.some((entry) => !entry || typeof entry !== "object")) throw new SourceHistoryProtocolError("SOURCE_VERSION_FEED_INVALID", "SAP ADT source-history feed contains an invalid entry.");
|
|
1321
|
+
return entries;
|
|
1322
|
+
}
|
|
1323
|
+
function extractContentLink(entry, ordinal) {
|
|
1324
|
+
const links = normalizeLinks(entry.link);
|
|
1325
|
+
const contentLink = links.find(isContentLink) ?? links.find(isPlainTextContentLink) ?? contentElementAsLink(entry);
|
|
1326
|
+
const href = contentLink && asOptionalString(contentLink.href);
|
|
1327
|
+
if (!contentLink || !href) throw new SourceHistoryProtocolError("SOURCE_VERSION_CONTENT_LINK_MISSING", `Source-history entry ${ordinal} has no immutable content link.`, ordinal);
|
|
1328
|
+
return {
|
|
1329
|
+
...contentLink,
|
|
1330
|
+
href
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
function extractEntryTransports(links) {
|
|
1334
|
+
return [...new Set(links.map(transportFromLink).filter((transport) => Boolean(transport)))];
|
|
1335
|
+
}
|
|
1336
|
+
function sourceVersionRefFromEntry(entry, ordinal, versionsUri) {
|
|
1337
|
+
const id = asOptionalString(entry.id);
|
|
1338
|
+
if (!id) throw new SourceHistoryProtocolError("SOURCE_VERSION_ID_MISSING", `Source-history entry ${ordinal} has no stable version id.`, ordinal);
|
|
1339
|
+
const contentLink = extractContentLink(entry, ordinal);
|
|
1340
|
+
return {
|
|
1341
|
+
id,
|
|
1342
|
+
ordinal,
|
|
1343
|
+
title: asOptionalString(entry.title),
|
|
1344
|
+
sourceUri: resolveAdtUri(contentLink.href, versionsUri),
|
|
1345
|
+
contentType: asOptionalString(contentLink.type),
|
|
1346
|
+
etag: asOptionalString(contentLink.etag),
|
|
1347
|
+
updatedAt: asOptionalString(entry.updated),
|
|
1348
|
+
author: authorName(entry.author),
|
|
1349
|
+
transports: extractEntryTransports(normalizeLinks(entry.link))
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
function normalizeSourceVersionFeed(payload, versionsUri) {
|
|
1353
|
+
assertAdtUri(versionsUri);
|
|
1354
|
+
return readSourceVersionEntries(assertSourceVersionFeed(assertSourceVersionPayload(payload).feed)).map((entry, ordinal) => sourceVersionRefFromEntry(entry, ordinal, versionsUri));
|
|
1355
|
+
}
|
|
1356
|
+
var SourceHistoryService = class {
|
|
1357
|
+
adt;
|
|
1358
|
+
boundedSourceReader;
|
|
1359
|
+
constructor(adt, boundedSourceReader) {
|
|
1360
|
+
this.adt = adt;
|
|
1361
|
+
this.boundedSourceReader = boundedSourceReader;
|
|
1362
|
+
}
|
|
1363
|
+
async listVersions(versionsUri) {
|
|
1364
|
+
assertAdtUri(versionsUri);
|
|
1365
|
+
return normalizeSourceVersionFeed(await this.adt.repository.sourceversions.list({ versionsUri }), versionsUri);
|
|
1366
|
+
}
|
|
1367
|
+
async readVersionSource(sourceUri) {
|
|
1368
|
+
assertAdtUri(sourceUri);
|
|
1369
|
+
return this.adt.repository.sourceversions.get({ sourceUri });
|
|
1370
|
+
}
|
|
1371
|
+
async readVersionSourceBounded(sourceUri, maxBytes) {
|
|
1372
|
+
assertAdtUri(sourceUri);
|
|
1373
|
+
if (!this.boundedSourceReader) throw new Error("Bounded source reads are not configured.");
|
|
1374
|
+
try {
|
|
1375
|
+
return await this.boundedSourceReader(sourceUri, maxBytes);
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
if (error instanceof AdtResponseTooLargeError) throw new SourceVersionTooLargeError(error.maxBytes, error.receivedBytes);
|
|
1378
|
+
throw error;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
function createSourceHistoryService(adt, boundedSourceReader) {
|
|
1383
|
+
return new SourceHistoryService(adt, boundedSourceReader);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
//#endregion
|
|
1387
|
+
//#region src/client.ts
|
|
1388
|
+
/**
|
|
1389
|
+
* ADT Client V2 - Contract-Based Architecture
|
|
1390
|
+
*
|
|
1391
|
+
* Provides typed access to SAP ADT REST APIs:
|
|
1392
|
+
* - client.adt.* - Typed ADT REST contracts (speci-generated)
|
|
1393
|
+
* - client.fetch() - Generic HTTP utility for raw requests
|
|
1394
|
+
*
|
|
1395
|
+
* Note: Business logic (transport management, etc.) has moved to @abapify/adk
|
|
1396
|
+
* Use AdkTransportRequest for transport operations.
|
|
1397
|
+
*/
|
|
1398
|
+
function createAdtClient(config) {
|
|
1399
|
+
const adapter = createAdtAdapter(config);
|
|
1400
|
+
const adtClient = createAdtClient$1({
|
|
1401
|
+
baseUrl: config.baseUrl,
|
|
1402
|
+
adapter
|
|
1403
|
+
});
|
|
1404
|
+
const fetchFn = async (url, options) => {
|
|
1405
|
+
const requestOptions = {
|
|
1406
|
+
method: options?.method || "GET",
|
|
1407
|
+
url,
|
|
1408
|
+
headers: options?.headers || {},
|
|
1409
|
+
body: options?.body
|
|
1410
|
+
};
|
|
1411
|
+
return adapter.request(requestOptions);
|
|
1412
|
+
};
|
|
1413
|
+
const readTextBounded = async (url, maxBytes, options) => adapter.readTextBounded({
|
|
1414
|
+
url,
|
|
1415
|
+
headers: options?.headers
|
|
1416
|
+
}, maxBytes);
|
|
1417
|
+
return {
|
|
1418
|
+
/**
|
|
1419
|
+
* Typed ADT REST contracts
|
|
1420
|
+
* Direct access to speci-generated client methods
|
|
1421
|
+
*/
|
|
1422
|
+
adt: adtClient,
|
|
1423
|
+
/**
|
|
1424
|
+
* Services layer - business logic orchestration
|
|
1425
|
+
* Higher-level operations that combine multiple contract calls
|
|
1426
|
+
*/
|
|
1427
|
+
services: {
|
|
1428
|
+
transports: createTransportService(adtClient),
|
|
1429
|
+
sourceHistory: createSourceHistoryService(adtClient, (sourceUri, maxBytes) => readTextBounded(sourceUri, maxBytes, { headers: { Accept: "text/plain" } }))
|
|
1430
|
+
},
|
|
1431
|
+
/**
|
|
1432
|
+
* Generic fetch utility for arbitrary ADT endpoints
|
|
1433
|
+
* Useful for debugging, testing, or accessing undocumented APIs
|
|
1434
|
+
*
|
|
1435
|
+
* @param url - The ADT endpoint path (e.g., '/sap/bc/adt/core/http/sessions')
|
|
1436
|
+
* @param options - Request options (method, headers, body)
|
|
1437
|
+
* @returns Raw response
|
|
1438
|
+
*/
|
|
1439
|
+
fetch: fetchFn,
|
|
1440
|
+
/**
|
|
1441
|
+
* Read an authenticated plain-text endpoint while enforcing a byte limit.
|
|
1442
|
+
*/
|
|
1443
|
+
readTextBounded,
|
|
1444
|
+
/**
|
|
1445
|
+
* Clear cached ETag for a specific URL, or all ETags if no URL given.
|
|
1446
|
+
* Use after operations that may invalidate server-side ETags.
|
|
1447
|
+
*/
|
|
1448
|
+
clearETag: (url) => adapter.clearETag(url)
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
//#endregion
|
|
1453
|
+
//#region src/plugins/logging.ts
|
|
1454
|
+
/**
|
|
1455
|
+
* Logging plugin - logs requests and responses
|
|
1456
|
+
*/
|
|
1457
|
+
var LoggingPlugin = class {
|
|
1458
|
+
logger;
|
|
1459
|
+
name = "logging";
|
|
1460
|
+
constructor(logger = console.log) {
|
|
1461
|
+
this.logger = logger;
|
|
1462
|
+
}
|
|
1463
|
+
process(context) {
|
|
1464
|
+
this.logger(`[${context.method}] ${context.url}`, {
|
|
1465
|
+
contentType: context.contentType,
|
|
1466
|
+
hasParsedData: !!context.parsedData,
|
|
1467
|
+
rawSize: context.rawText.length
|
|
1468
|
+
});
|
|
1469
|
+
return context.parsedData;
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
//#endregion
|
|
1474
|
+
//#region src/plugins/file-logging.ts
|
|
1475
|
+
/**
|
|
1476
|
+
* File Logging Plugin - Saves HTTP responses to files
|
|
1477
|
+
*/
|
|
1478
|
+
/**
|
|
1479
|
+
* Plugin that logs HTTP responses to files
|
|
1480
|
+
* Similar to v1's FileLogger functionality
|
|
1481
|
+
*/
|
|
1482
|
+
var FileLoggingPlugin = class {
|
|
1483
|
+
config;
|
|
1484
|
+
name = "file-logging";
|
|
1485
|
+
constructor(config) {
|
|
1486
|
+
this.config = config;
|
|
1487
|
+
}
|
|
1488
|
+
process(context) {
|
|
1489
|
+
try {
|
|
1490
|
+
const filePath = this.generateFilePath(context.url, context.method);
|
|
1491
|
+
const fullPath = join(this.config.outputDir, filePath);
|
|
1492
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
1493
|
+
writeFileSync(fullPath, context.rawText, "utf8");
|
|
1494
|
+
this.config.logger?.trace(`Wrote response to: ${fullPath}`);
|
|
1495
|
+
if (this.config.writeMetadata) {
|
|
1496
|
+
const metadataPath = this.getMetadataPath(fullPath);
|
|
1497
|
+
const metadata = {
|
|
1498
|
+
url: context.url,
|
|
1499
|
+
method: context.method,
|
|
1500
|
+
contentType: context.contentType,
|
|
1501
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1502
|
+
size: context.rawText.length
|
|
1503
|
+
};
|
|
1504
|
+
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf8");
|
|
1505
|
+
this.config.logger?.trace(`Wrote metadata to: ${metadataPath}`);
|
|
1506
|
+
}
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
this.config.logger?.error(`Failed to write response file: ${error instanceof Error ? error.message : String(error)}`);
|
|
1509
|
+
}
|
|
1510
|
+
return context.parsedData;
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Generate file path for HTTP response
|
|
1514
|
+
* Converts URL to fixture-style path structure
|
|
1515
|
+
*/
|
|
1516
|
+
generateFilePath(url, method) {
|
|
1517
|
+
const urlObj = new URL(url);
|
|
1518
|
+
let path = urlObj.pathname;
|
|
1519
|
+
path = path.replace(/^\/sap\/bc\/adt\/?/, "");
|
|
1520
|
+
if (!path || path === "discovery") return "./adt/core/discovery.xml";
|
|
1521
|
+
const [basePath] = path.split("?");
|
|
1522
|
+
const segments = basePath.split("/").filter((s) => s);
|
|
1523
|
+
const sourceTypes = [
|
|
1524
|
+
"main",
|
|
1525
|
+
"definitions",
|
|
1526
|
+
"implementations",
|
|
1527
|
+
"macros",
|
|
1528
|
+
"testclasses"
|
|
1529
|
+
];
|
|
1530
|
+
const lastSegment = segments[segments.length - 1];
|
|
1531
|
+
if (sourceTypes.includes(lastSegment)) return `./adt/${segments.join("/")}`;
|
|
1532
|
+
const requestId = Date.now().toString() + Math.random().toString(36).slice(2, 7);
|
|
1533
|
+
let dirPath = `./adt/${segments.join("/")}`;
|
|
1534
|
+
if (urlObj.search) {
|
|
1535
|
+
const sanitizedQuery = urlObj.search.slice(1).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
|
|
1536
|
+
dirPath += `/${sanitizedQuery}`;
|
|
1537
|
+
}
|
|
1538
|
+
const methodPrefix = method !== "GET" ? `${method.toLowerCase()}-` : "";
|
|
1539
|
+
return `${dirPath}/${methodPrefix}${requestId}-response.xml`;
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Get metadata file path for a response file
|
|
1543
|
+
*/
|
|
1544
|
+
getMetadataPath(responsePath) {
|
|
1545
|
+
if (/-response\.xml$/.test(responsePath)) return responsePath.replace(/-response\.xml$/, "-metadata.json");
|
|
1546
|
+
if (/\.xml$/.test(responsePath)) return responsePath.replace(/\.xml$/, ".metadata.json");
|
|
1547
|
+
return `${responsePath}.metadata.json`;
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1551
|
+
//#endregion
|
|
1552
|
+
export { AdtResponseTooLargeError, CookieStore, CsrfTokenManager, FileLoggingPlugin, LoggingPlugin, SessionManager, SourceHistoryProtocolError, SourceHistoryService, SourceVersionTooLargeError, TransportService, UserService, adtContract, assertAdtUri, createAdtAdapter, createAdtClient, createSourceHistoryService, createTransportService, createUserService, normalizeSourceVersionFeed, runWithAdtAbortSignal };
|
|
1553
|
+
//# sourceMappingURL=index.mjs.map
|