@abapify/adt-client 0.3.6 → 0.4.1

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/dist/index.mjs ADDED
@@ -0,0 +1,969 @@
1
+ import { exception } from "@abapify/adt-schemas";
2
+ import { adtContract, createAdtClient as createAdtClient$1 } from "@abapify/adt-contracts";
3
+ import { mkdirSync, writeFileSync } from "fs";
4
+ import { dirname, join } from "path";
5
+
6
+ //#region src/utils/session.ts
7
+ /**
8
+ * Cookie Store - Manages HTTP cookies for stateful sessions
9
+ */
10
+ var CookieStore = class {
11
+ cookies = /* @__PURE__ */ new Map();
12
+ /**
13
+ * Parse Set-Cookie header and store cookies
14
+ * Handles complex cookie strings with expires dates
15
+ */
16
+ parseCookies(setCookieHeader) {
17
+ const cookieStrings = this.splitCookieHeader(setCookieHeader);
18
+ for (const cookieString of cookieStrings) {
19
+ const [name, value] = cookieString.split(";")[0].trim().split("=", 2);
20
+ if (name && value && !name.includes("expires") && !name.includes("path")) this.cookies.set(name.trim(), value.trim());
21
+ }
22
+ }
23
+ /**
24
+ * Split Set-Cookie header by commas, avoiding splitting on expires dates
25
+ */
26
+ splitCookieHeader(setCookieHeader) {
27
+ const cookies = [];
28
+ let current = "";
29
+ let inExpires = false;
30
+ for (let i = 0; i < setCookieHeader.length; i++) {
31
+ const char = setCookieHeader[i];
32
+ if (char === "," && !inExpires) {
33
+ if (current.trim()) cookies.push(current.trim());
34
+ current = "";
35
+ } else {
36
+ current += char;
37
+ if (current.toLowerCase().includes("expires=")) inExpires = true;
38
+ if (inExpires && (char === ";" || i === setCookieHeader.length - 1)) inExpires = false;
39
+ }
40
+ }
41
+ if (current.trim()) cookies.push(current.trim());
42
+ return cookies;
43
+ }
44
+ /**
45
+ * Get Cookie header value for requests
46
+ */
47
+ getCookieHeader() {
48
+ if (this.cookies.size === 0) return;
49
+ return Array.from(this.cookies.entries()).map(([name, value]) => `${name}=${value}`).join("; ");
50
+ }
51
+ /**
52
+ * Check if we have any cookies
53
+ */
54
+ hasCookies() {
55
+ return this.cookies.size > 0;
56
+ }
57
+ /**
58
+ * Clear all cookies
59
+ */
60
+ clear() {
61
+ this.cookies.clear();
62
+ }
63
+ /**
64
+ * Get all cookies as a map (for testing/debugging)
65
+ */
66
+ getAll() {
67
+ return new Map(this.cookies);
68
+ }
69
+ /**
70
+ * Inject pre-existing cookies (e.g., from SAML authentication)
71
+ * @param cookieString Cookie string in "name=value; name2=value2" format
72
+ */
73
+ injectCookie(cookieString) {
74
+ const cookies = cookieString.split("; ");
75
+ for (const cookie of cookies) {
76
+ const [name, ...valueParts] = cookie.split("=");
77
+ const value = valueParts.join("=");
78
+ if (name && value) this.cookies.set(name.trim(), value.trim());
79
+ }
80
+ }
81
+ };
82
+ /**
83
+ * CSRF Token Manager - Handles CSRF token caching and initialization
84
+ */
85
+ var CsrfTokenManager = class {
86
+ cachedToken;
87
+ /**
88
+ * Extract CSRF token from cookies (SAP stores it there)
89
+ */
90
+ extractFromCookies(cookies) {
91
+ const xsrfEntry = Array.from(cookies.entries()).find(([key]) => key.toLowerCase().includes("xsrf") || key.toLowerCase().includes("csrf"));
92
+ if (!xsrfEntry) return;
93
+ const cookieValue = xsrfEntry[1];
94
+ const decodedToken = decodeURIComponent(cookieValue);
95
+ const tokenMatch = decodedToken.match(/^([A-Za-z0-9+/_-]+=*)/);
96
+ const actualToken = tokenMatch ? tokenMatch[1] : decodedToken;
97
+ if (actualToken && actualToken !== "Required" && actualToken !== "fetch" && actualToken !== "Fetch") return actualToken;
98
+ }
99
+ /**
100
+ * Extract CSRF token from response header
101
+ */
102
+ extractFromHeader(headerValue) {
103
+ if (!headerValue || headerValue === "Required" || headerValue === "fetch" || headerValue === "Fetch") return;
104
+ return headerValue;
105
+ }
106
+ /**
107
+ * Cache a CSRF token
108
+ */
109
+ cache(token) {
110
+ this.cachedToken = token;
111
+ }
112
+ /**
113
+ * Get cached CSRF token
114
+ */
115
+ getCached() {
116
+ return this.cachedToken;
117
+ }
118
+ /**
119
+ * Clear cached CSRF token (e.g., on 403 errors)
120
+ */
121
+ clear() {
122
+ this.cachedToken = void 0;
123
+ }
124
+ /**
125
+ * Check if we have a cached token
126
+ */
127
+ hasCached() {
128
+ return !!this.cachedToken;
129
+ }
130
+ /**
131
+ * Alias for hasCached() - for consistency with SessionManager API
132
+ */
133
+ hasToken() {
134
+ return this.hasCached();
135
+ }
136
+ };
137
+ /**
138
+ * ETag Manager - Tracks ETags for optimistic locking
139
+ */
140
+ var ETagManager = class {
141
+ etags = /* @__PURE__ */ new Map();
142
+ /**
143
+ * Extract and cache ETag from response header
144
+ * @param url The request URL (used as key)
145
+ * @param headerValue The ETag header value
146
+ */
147
+ cacheFromHeader(url, headerValue) {
148
+ if (headerValue) {
149
+ const key = this.normalizeUrl(url);
150
+ this.etags.set(key, headerValue);
151
+ }
152
+ }
153
+ /**
154
+ * Get cached ETag for a URL
155
+ */
156
+ get(url) {
157
+ const key = this.normalizeUrl(url);
158
+ return this.etags.get(key);
159
+ }
160
+ /**
161
+ * Clear cached ETag for a URL
162
+ */
163
+ clear(url) {
164
+ if (url) {
165
+ const key = this.normalizeUrl(url);
166
+ this.etags.delete(key);
167
+ } else this.etags.clear();
168
+ }
169
+ /**
170
+ * Normalize URL to use as cache key.
171
+ * Removes query params, trailing slashes, and lowercases the path.
172
+ * SAP ADT URLs are case-insensitive, so /fmodules/ZAGE_FM and
173
+ * /fmodules/zage_fm must share the same ETag cache entry.
174
+ */
175
+ normalizeUrl(url) {
176
+ try {
177
+ return new URL(url).pathname.replace(/\/$/, "").toLowerCase();
178
+ } catch {
179
+ return url.split("?")[0].replace(/\/$/, "").toLowerCase();
180
+ }
181
+ }
182
+ };
183
+ /**
184
+ * Session Manager - Orchestrates cookies and CSRF tokens
185
+ */
186
+ var SessionManager = class {
187
+ cookieStore = new CookieStore();
188
+ csrfManager = new CsrfTokenManager();
189
+ etagManager = new ETagManager();
190
+ securitySessionActive = false;
191
+ constructor(logger) {
192
+ this.logger = logger;
193
+ }
194
+ /**
195
+ * Process response to update session state
196
+ * Extracts cookies, CSRF tokens, and ETags
197
+ * @param response The HTTP response
198
+ * @param url Optional URL for ETag caching (required for ETag tracking)
199
+ */
200
+ processResponse(response, url) {
201
+ if (url) {
202
+ const etag = response.headers.get("etag");
203
+ if (etag) {
204
+ this.etagManager.cacheFromHeader(url, etag);
205
+ this.logger?.debug(`Session: ETag cached for ${url}: ${etag}`);
206
+ }
207
+ }
208
+ const setCookieHeader = response.headers.get("set-cookie");
209
+ if (setCookieHeader) {
210
+ this.cookieStore.parseCookies(setCookieHeader);
211
+ this.logger?.debug("Session: Cookies updated from response");
212
+ }
213
+ const csrfHeader = response.headers.get("x-csrf-token");
214
+ const csrfToken = this.csrfManager.extractFromHeader(csrfHeader);
215
+ if (csrfToken) {
216
+ this.csrfManager.cache(csrfToken);
217
+ this.logger?.debug("Session: CSRF token cached from header");
218
+ }
219
+ if (!csrfToken && this.cookieStore.hasCookies()) {
220
+ const cookieCsrf = this.csrfManager.extractFromCookies(this.cookieStore.getAll());
221
+ if (cookieCsrf) {
222
+ this.csrfManager.cache(cookieCsrf);
223
+ this.logger?.debug("Session: CSRF token cached from cookies");
224
+ }
225
+ }
226
+ }
227
+ /**
228
+ * Get headers for next request
229
+ * @param method HTTP method
230
+ * @param url Optional URL for ETag lookup (for PUT/PATCH with optimistic locking)
231
+ */
232
+ getRequestHeaders(method, url) {
233
+ const headers = {};
234
+ if (this.securitySessionActive) headers["x-sap-security-session"] = "use";
235
+ const cookieHeader = this.cookieStore.getCookieHeader();
236
+ if (cookieHeader) headers.Cookie = cookieHeader;
237
+ if ([
238
+ "POST",
239
+ "PUT",
240
+ "DELETE",
241
+ "PATCH"
242
+ ].includes(method.toUpperCase())) {
243
+ const cachedToken = this.csrfManager.getCached();
244
+ if (cachedToken) headers["x-csrf-token"] = cachedToken;
245
+ else headers["x-csrf-token"] = "Fetch";
246
+ }
247
+ if (["PUT", "PATCH"].includes(method.toUpperCase()) && url) {
248
+ const etag = this.etagManager.get(url);
249
+ if (etag) {
250
+ headers["If-Match"] = etag;
251
+ this.logger?.debug(`Session: Using cached ETag for If-Match: ${etag}`);
252
+ }
253
+ }
254
+ return headers;
255
+ }
256
+ /**
257
+ * Get cached ETag for a URL
258
+ */
259
+ getETag(url) {
260
+ return this.etagManager.get(url);
261
+ }
262
+ /**
263
+ * Clear cached ETag for a URL
264
+ */
265
+ clearETag(url) {
266
+ this.etagManager.clear(url);
267
+ }
268
+ /**
269
+ * Initialize CSRF token using the Eclipse ADT security session flow:
270
+ *
271
+ * 1. GET /sessions + x-sap-security-session: create → create security session
272
+ * 2. GET /sessions + x-sap-security-session: use + x-csrf-token: Fetch → get CSRF token
273
+ * 3. DELETE /sessions/<id> + x-sap-security-session: use + x-csrf-token → destroy session
274
+ *
275
+ * The CSRF token survives the session deletion and remains valid for
276
+ * all subsequent lock/unlock operations. Deleting the session frees
277
+ * the slot — SAP allows only one security session per user.
278
+ *
279
+ * @param baseUrl - SAP system base URL
280
+ * @param authHeader - Authorization header (Basic/Bearer), or undefined for cookie auth
281
+ * @param client - SAP client number
282
+ * @param language - SAP language
283
+ */
284
+ async initializeCsrf(baseUrl, authHeader, client, language) {
285
+ const sessionsUrl = new URL("/sap/bc/adt/core/http/sessions", baseUrl);
286
+ if (client) sessionsUrl.searchParams.append("sap-client", client);
287
+ if (language) sessionsUrl.searchParams.append("sap-language", language);
288
+ /** Build common headers, optionally including Authorization and cookies */
289
+ const baseHeaders = () => {
290
+ const h = {
291
+ Accept: "application/vnd.sap.adt.core.http.session.v3+xml",
292
+ "X-sap-adt-sessiontype": "stateful"
293
+ };
294
+ if (authHeader) h.Authorization = authHeader;
295
+ const cookie = this.cookieStore.getCookieHeader();
296
+ if (cookie) h.Cookie = cookie;
297
+ return h;
298
+ };
299
+ try {
300
+ this.logger?.debug("Session: Creating security session");
301
+ const createResponse = await fetch(sessionsUrl.toString(), {
302
+ method: "GET",
303
+ headers: {
304
+ ...baseHeaders(),
305
+ "x-sap-security-session": "create"
306
+ }
307
+ });
308
+ if (!createResponse.ok) {
309
+ this.logger?.warn(`Session: Security session creation failed with status ${createResponse.status}`);
310
+ return false;
311
+ }
312
+ this.processResponse(createResponse);
313
+ const sessionPath = (await createResponse.text()).match(/href="([^"]*\/sessions\/[^"]*)"/)?.[1];
314
+ this.logger?.debug("Session: Fetching CSRF token");
315
+ const csrfResponse = await fetch(sessionsUrl.toString(), {
316
+ method: "GET",
317
+ headers: {
318
+ ...baseHeaders(),
319
+ "x-sap-security-session": "use",
320
+ "x-csrf-token": "Fetch"
321
+ }
322
+ });
323
+ if (!csrfResponse.ok) {
324
+ this.logger?.warn(`Session: CSRF fetch failed with status ${csrfResponse.status}`);
325
+ return false;
326
+ }
327
+ this.processResponse(csrfResponse);
328
+ if (!this.csrfManager.hasCached()) {
329
+ this.logger?.warn("Session: CSRF fetch succeeded but no token found in response");
330
+ return false;
331
+ }
332
+ this.securitySessionActive = true;
333
+ this.logger?.debug("Session: CSRF token acquired");
334
+ if (sessionPath) {
335
+ const deleteUrl = new URL(sessionPath, baseUrl);
336
+ if (client) deleteUrl.searchParams.append("sap-client", client);
337
+ try {
338
+ this.logger?.debug(`Session: Deleting security session ${sessionPath}`);
339
+ await fetch(deleteUrl.toString(), {
340
+ method: "DELETE",
341
+ headers: {
342
+ ...baseHeaders(),
343
+ "x-sap-security-session": "use",
344
+ "x-csrf-token": this.csrfManager.getCached()
345
+ }
346
+ });
347
+ this.logger?.debug("Session: Security session deleted");
348
+ } catch {
349
+ this.logger?.debug("Session: Failed to delete security session (will expire)");
350
+ }
351
+ }
352
+ return true;
353
+ } catch (error) {
354
+ this.logger?.error(`Session: CSRF initialization error: ${error instanceof Error ? error.message : String(error)}`);
355
+ return false;
356
+ }
357
+ }
358
+ /**
359
+ * Clear all session state (cookies and CSRF)
360
+ */
361
+ clear() {
362
+ this.cookieStore.clear();
363
+ this.csrfManager.clear();
364
+ this.securitySessionActive = false;
365
+ this.logger?.debug("Session: Cleared all session state (cookies and CSRF)");
366
+ }
367
+ /**
368
+ * Check if we have a cached CSRF token
369
+ */
370
+ hasCsrfToken() {
371
+ return this.csrfManager.hasCached();
372
+ }
373
+ /**
374
+ * Get Cookie header value for requests
375
+ */
376
+ getCookieHeader() {
377
+ return this.cookieStore.getCookieHeader();
378
+ }
379
+ /**
380
+ * Get session type header value
381
+ * SAP ADT requires 'stateful' for operations that need session persistence
382
+ */
383
+ getSessionTypeHeader() {
384
+ return "stateful";
385
+ }
386
+ /**
387
+ * Inject a pre-existing cookie (e.g., from SAML authentication)
388
+ * @param cookieString Cookie string in "name=value" format
389
+ */
390
+ injectCookie(cookieString) {
391
+ this.cookieStore.injectCookie(cookieString);
392
+ this.logger?.debug(`Session: Injected cookie from external source`);
393
+ }
394
+ };
395
+
396
+ //#endregion
397
+ //#region src/errors.ts
398
+ /**
399
+ * ADT Error Handling
400
+ *
401
+ * SAP ADT returns errors in a standard XML format defined by exception.xsd:
402
+ * <exc:exception xmlns:exc="http://www.sap.com/abapxml/types/communicationframework">
403
+ * <namespace id="..."/>
404
+ * <type id="..."/>
405
+ * <message lang="EN">Human readable error message</message>
406
+ * <localizedMessage lang="EN">Localized message</localizedMessage>
407
+ * <properties>
408
+ * <entry key="key1">value1</entry>
409
+ * </properties>
410
+ * </exc:exception>
411
+ *
412
+ * Uses the generated exception schema from @abapify/adt-schemas for type-safe parsing.
413
+ */
414
+ /**
415
+ * Custom error class for ADT exceptions
416
+ *
417
+ * Provides structured access to SAP ADT error details.
418
+ */
419
+ var AdtError = class AdtError extends Error {
420
+ /** HTTP status code */
421
+ status;
422
+ /** HTTP status text */
423
+ statusText;
424
+ /** Parsed ADT exception data (if available) */
425
+ exception;
426
+ /** Raw response body */
427
+ rawBody;
428
+ /** Request URL */
429
+ url;
430
+ /** HTTP method */
431
+ method;
432
+ constructor(options) {
433
+ const baseMsg = `HTTP ${options.status}: ${options.statusText}`;
434
+ const detailMsg = options.exception?.message || options.exception?.localizedMessage;
435
+ const fullMsg = detailMsg ? `${baseMsg} - ${detailMsg}` : baseMsg;
436
+ super(fullMsg);
437
+ this.name = "AdtError";
438
+ this.status = options.status;
439
+ this.statusText = options.statusText;
440
+ this.url = options.url;
441
+ this.method = options.method;
442
+ this.rawBody = options.rawBody;
443
+ this.exception = options.exception;
444
+ if (Error.captureStackTrace) Error.captureStackTrace(this, AdtError);
445
+ }
446
+ /**
447
+ * Get the most relevant error message
448
+ */
449
+ get detailMessage() {
450
+ return this.exception?.localizedMessage || this.exception?.message || this.statusText;
451
+ }
452
+ /**
453
+ * Check if this is a specific error type
454
+ */
455
+ isType(type) {
456
+ return this.exception?.type === type;
457
+ }
458
+ /**
459
+ * Check if this is from a specific namespace
460
+ */
461
+ isNamespace(namespace) {
462
+ return this.exception?.namespace === namespace;
463
+ }
464
+ /**
465
+ * Get a property value from the exception
466
+ */
467
+ getProperty(key) {
468
+ return this.exception?.properties?.[key];
469
+ }
470
+ };
471
+ /**
472
+ * Parse ADT exception XML into structured data
473
+ *
474
+ * Uses the generated exception schema from @abapify/adt-schemas for type-safe parsing.
475
+ */
476
+ function parseAdtException(xml) {
477
+ if (!xml.includes("exception") || !/xmlns(?::[\w-]+)?="http:\/\/www\.sap\.com\/abapxml\/types\/communicationframework"/.test(xml)) return;
478
+ try {
479
+ const exc = exception.parse(xml).exception;
480
+ if (!exc) return;
481
+ const result = {};
482
+ if (exc.namespace?.id) result.namespace = exc.namespace.id;
483
+ if (exc.type?.id) result.type = exc.type.id;
484
+ if (exc.message?.$value) result.message = exc.message.$value;
485
+ if (exc.localizedMessage?.$value) result.localizedMessage = exc.localizedMessage.$value;
486
+ if (exc.properties?.entry) {
487
+ result.properties = {};
488
+ const entries = Array.isArray(exc.properties.entry) ? exc.properties.entry : [exc.properties.entry];
489
+ for (const entry of entries) if (entry.key && entry.$value) result.properties[entry.key] = entry.$value;
490
+ }
491
+ if (!result.namespace && !result.type && !result.message && !result.localizedMessage) return;
492
+ return result;
493
+ } catch {
494
+ return;
495
+ }
496
+ }
497
+ /**
498
+ * Create an AdtError from HTTP response details
499
+ */
500
+ function createAdtError(status, statusText, url, method, rawBody) {
501
+ return new AdtError({
502
+ status,
503
+ statusText,
504
+ url,
505
+ method,
506
+ rawBody,
507
+ exception: parseAdtException(rawBody)
508
+ });
509
+ }
510
+
511
+ //#endregion
512
+ //#region src/adapter.ts
513
+ function createAdtAdapter(config) {
514
+ const { baseUrl, username, password, cookieHeader, authorizationHeader, client, language, logger, plugins = [], onSessionExpired } = config;
515
+ const isSamlAuth = !!cookieHeader;
516
+ const basicCredentials = `${username}:${password}`;
517
+ const authHeader = authorizationHeader ?? (isSamlAuth ? void 0 : `Basic ${Buffer.from(basicCredentials).toString("base64")}`);
518
+ const sessionManager = new SessionManager(logger);
519
+ if (cookieHeader) sessionManager.injectCookie(cookieHeader);
520
+ return {
521
+ async request(options) {
522
+ logger?.debug("=== ADAPTER REQUEST START ===");
523
+ logger?.debug("options.url:", options.url);
524
+ logger?.debug("options.method:", options.method);
525
+ logger?.debug("options.body:", options.body ? "present" : "undefined");
526
+ logger?.debug("options.bodySchema:", options.bodySchema ? "present" : "undefined");
527
+ const url = new URL(options.url, baseUrl);
528
+ if (options.query) Object.entries(options.query).forEach(([key, value]) => {
529
+ url.searchParams.append(key, String(value));
530
+ });
531
+ if (client) url.searchParams.append("sap-client", client);
532
+ if (language) url.searchParams.append("sap-language", language);
533
+ if ([
534
+ "POST",
535
+ "PUT",
536
+ "DELETE",
537
+ "PATCH"
538
+ ].includes(options.method.toUpperCase()) && !sessionManager.hasCsrfToken()) {
539
+ logger?.debug("Adapter: Initializing CSRF token before write operation");
540
+ await sessionManager.initializeCsrf(baseUrl, authHeader, client, language);
541
+ }
542
+ const headers = {
543
+ "X-sap-adt-sessiontype": sessionManager.getSessionTypeHeader(),
544
+ ...sessionManager.getRequestHeaders(options.method, url.pathname),
545
+ ...options.headers
546
+ };
547
+ if (authHeader) headers.Authorization = authHeader;
548
+ let bodySerializableSchema;
549
+ if (options.bodySchema && typeof options.bodySchema === "object") {
550
+ if ("build" in options.bodySchema && typeof options.bodySchema.build === "function") bodySerializableSchema = options.bodySchema;
551
+ }
552
+ let serializableSchema;
553
+ if (options.responses) {
554
+ const schema200 = options.responses[200];
555
+ logger?.debug("Schema detection:", {
556
+ hasSchema: !!schema200,
557
+ type: typeof schema200,
558
+ keys: schema200 && typeof schema200 === "object" ? Object.keys(schema200) : []
559
+ });
560
+ if (schema200 && typeof schema200 === "object" && "parse" in schema200 && typeof schema200.parse === "function") serializableSchema = schema200;
561
+ }
562
+ let requestBody;
563
+ const body = options.body;
564
+ logger?.debug("Request URL: " + options.url);
565
+ logger?.debug("Request method: " + options.method);
566
+ logger?.debug("options.body: " + (options.body ? JSON.stringify(options.body).substring(0, 500) : "UNDEFINED"));
567
+ logger?.debug("options.bodySchema: " + (options.bodySchema ? "present" : "undefined"));
568
+ logger?.debug("bodySerializableSchema: " + (bodySerializableSchema ? "present" : "undefined"));
569
+ logger?.debug("Body type: " + typeof body);
570
+ logger?.debug("Body value: " + (body ? JSON.stringify(body).substring(0, 500) : "UNDEFINED/NULL"));
571
+ if (body !== void 0 && body !== null) if (typeof body === "string") {
572
+ requestBody = body;
573
+ logger?.debug("Body: using raw string");
574
+ logger?.debug("Body content (first 200 chars):", requestBody.substring(0, 200));
575
+ } else if (bodySerializableSchema) try {
576
+ logger?.debug("Calling bodySerializableSchema.build with body: " + JSON.stringify(body).substring(0, 300));
577
+ requestBody = bodySerializableSchema.build(body);
578
+ logger?.debug("Body: serialized using Serializable.build()");
579
+ logger?.debug("Body output type: " + typeof requestBody);
580
+ logger?.debug("Body output length: " + requestBody?.length);
581
+ logger?.debug("Body content (first 500 chars): " + requestBody?.substring(0, 500));
582
+ } catch (buildError) {
583
+ logger?.error("Schema build() threw error: " + (buildError instanceof Error ? buildError.message : String(buildError)));
584
+ logger?.error("Stack: " + (buildError instanceof Error ? buildError.stack : "N/A"));
585
+ throw buildError;
586
+ }
587
+ else {
588
+ requestBody = JSON.stringify(body);
589
+ logger?.debug("Body: serialized as JSON");
590
+ }
591
+ logger?.debug(`HTTP ${options.method} ${url.toString()}`);
592
+ {
593
+ const safeHeaders = {
594
+ ...headers,
595
+ ...headers.Authorization ? { Authorization: headers.Authorization.substring(0, 10) + "***" } : {},
596
+ ...headers.Cookie ? { Cookie: "***" } : {},
597
+ ...headers["x-csrf-token"] ? { "x-csrf-token": "***" } : {}
598
+ };
599
+ logger?.debug("Request headers: " + JSON.stringify(safeHeaders, null, 2));
600
+ }
601
+ const response = await fetch(url.toString(), {
602
+ method: options.method,
603
+ headers,
604
+ body: requestBody
605
+ });
606
+ sessionManager.processResponse(response, url.pathname);
607
+ logger?.debug(`Response: ${response.status} ${response.statusText}`);
608
+ logger?.debug("Response headers: " + JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
609
+ if (!response.ok) {
610
+ const errorBody = await response.text();
611
+ const adtError = createAdtError(response.status, response.statusText, url.toString(), options.method, errorBody);
612
+ if (response.status === 412) {
613
+ const sentETag = headers["If-Match"] ?? "(none)";
614
+ const diag = `[If-Match: ${sentETag}, URL: ${url.pathname}]`;
615
+ logger?.error(`ETag mismatch: ${options.method} ${url.pathname} sent If-Match: ${sentETag}`);
616
+ Object.defineProperty(adtError, "message", {
617
+ value: `${adtError.message} ${diag}`,
618
+ writable: true,
619
+ configurable: true
620
+ });
621
+ }
622
+ logger?.error(`Request failed - ${adtError.message} (${options.method} ${url.toString()})`);
623
+ if (adtError.exception) logger?.error(`ADT Exception: namespace=${adtError.exception.namespace}, type=${adtError.exception.type}`);
624
+ if (errorBody && !adtError.exception) logger?.error(`Response body: ${errorBody}`);
625
+ if (response.status === 403) if (!(adtError.exception?.type === "ExceptionResourceNoAccess" && (adtError.message.includes("currently editing") || adtError.message.includes("is being edited")))) {
626
+ sessionManager.clear();
627
+ logger?.warn("Session cleared due to 403 Forbidden response");
628
+ } else logger?.warn("Lock conflict (403) — session preserved (object locked by another session)");
629
+ throw adtError;
630
+ }
631
+ const contentType = response.headers.get("content-type") || "";
632
+ const rawText = await response.text();
633
+ if (contentType.includes("text/html") && rawText.includes("SAMLRequest")) {
634
+ sessionManager.clear();
635
+ logger?.warn("SAML redirect detected - session expired");
636
+ if (onSessionExpired) {
637
+ logger?.info("Attempting automatic re-authentication...");
638
+ try {
639
+ const newCookie = await onSessionExpired();
640
+ sessionManager.injectCookie(newCookie);
641
+ logger?.info("Re-authentication successful, retrying request...");
642
+ return this.request(options);
643
+ } catch (refreshError) {
644
+ logger?.error("Re-authentication failed");
645
+ throw new Error("Session expired - re-authentication failed", { cause: refreshError });
646
+ }
647
+ }
648
+ throw new Error("Session expired - SAML re-authentication required");
649
+ }
650
+ let data;
651
+ if (contentType.includes("application/json") || contentType.includes("+json")) data = JSON.parse(rawText);
652
+ else if (contentType.includes("text/") || contentType.includes("xml")) if (serializableSchema && contentType.includes("xml")) data = serializableSchema.parse(rawText);
653
+ else data = rawText;
654
+ else data = rawText;
655
+ if (plugins.length > 0) {
656
+ const context = {
657
+ rawText,
658
+ parsedData: data,
659
+ url: url.toString(),
660
+ method: options.method,
661
+ contentType
662
+ };
663
+ for (const plugin of plugins) {
664
+ const result = await plugin.process(context);
665
+ if (result !== void 0) {
666
+ data = result;
667
+ context.parsedData = result;
668
+ }
669
+ }
670
+ }
671
+ return data;
672
+ },
673
+ clearETag(url) {
674
+ sessionManager.clearETag(url);
675
+ }
676
+ };
677
+ }
678
+
679
+ //#endregion
680
+ //#region src/services/transports.ts
681
+ /**
682
+ * Transport Service - orchestrates CTS operations
683
+ */
684
+ var TransportService = class {
685
+ constructor(adt) {
686
+ this.adt = adt;
687
+ }
688
+ /**
689
+ * List transport requests
690
+ */
691
+ async list(options) {
692
+ const response = await this.adt.cts.transportrequests.list(options);
693
+ return this.extractRequests(response).map((req) => this.mapToTransport(req));
694
+ }
695
+ /**
696
+ * Get a single transport by number
697
+ */
698
+ async get(trkorr) {
699
+ const data = await this.adt.cts.transportrequests.get(trkorr);
700
+ const unwrapped = data?.root && typeof data.root === "object" ? data.root : data;
701
+ const request = unwrapped?.request ?? unwrapped;
702
+ return this.mapToTransport(request);
703
+ }
704
+ /**
705
+ * Create a new transport request
706
+ *
707
+ * Note: Full create implementation requires XML body building.
708
+ * For now, this is a placeholder that throws a helpful error.
709
+ * Use ADK's AdkTransportRequest.create() for full functionality.
710
+ */
711
+ async create(_options) {
712
+ throw new Error("Transport creation via service layer not yet implemented. Use ADK's AdkTransportRequest.create() for now.");
713
+ }
714
+ /**
715
+ * Delete a transport request
716
+ */
717
+ async delete(trkorr) {
718
+ await this.adt.cts.transportrequests.delete(trkorr);
719
+ }
720
+ /**
721
+ * Release a transport request
722
+ *
723
+ * Note: Release requires POST with specific action parameter.
724
+ * This is a placeholder for the full implementation.
725
+ */
726
+ async release(_trkorr) {
727
+ throw new Error("Transport release via service layer not yet implemented.");
728
+ }
729
+ /**
730
+ * Extract requests array from various response formats
731
+ */
732
+ extractRequests(response) {
733
+ if (!response) return [];
734
+ const data = response;
735
+ if (Array.isArray(data.request)) return data.request;
736
+ if (data.request && typeof data.request === "object") return [data.request];
737
+ if (Array.isArray(response)) return response;
738
+ if (data.root && typeof data.root === "object") return this.extractRequests(data.root);
739
+ return [];
740
+ }
741
+ /**
742
+ * Map raw request data to Transport interface
743
+ */
744
+ mapToTransport(req) {
745
+ const data = req;
746
+ return {
747
+ number: String(data.trkorr ?? data.number ?? ""),
748
+ desc: data.as4text ?? data.desc,
749
+ owner: data.as4user ?? data.owner,
750
+ status: data.trstatus ?? data.status,
751
+ type: data.trfunction ?? data.type,
752
+ target: data.tarsystem ?? data.target,
753
+ tasks: this.extractTasks(data)
754
+ };
755
+ }
756
+ /**
757
+ * Extract tasks from transport data
758
+ */
759
+ extractTasks(data) {
760
+ const tasks = data.task ?? data.tasks;
761
+ if (!tasks) return [];
762
+ return (Array.isArray(tasks) ? tasks : [tasks]).map((task) => {
763
+ const t = task;
764
+ return {
765
+ number: String(t.trkorr ?? t.number ?? ""),
766
+ owner: t.as4user ?? t.owner,
767
+ status: t.trstatus ?? t.status
768
+ };
769
+ });
770
+ }
771
+ };
772
+ /**
773
+ * Create transport service instance
774
+ */
775
+ function createTransportService(adt) {
776
+ return new TransportService(adt);
777
+ }
778
+
779
+ //#endregion
780
+ //#region src/services/users.ts
781
+ /** Extract entry array from atomFeed parsed response */
782
+ function extractEntries(data) {
783
+ const feedData = data.feed;
784
+ if (!feedData) return [];
785
+ const rawEntries = feedData.entry;
786
+ if (!rawEntries) return [];
787
+ return Array.isArray(rawEntries) ? rawEntries : [rawEntries];
788
+ }
789
+ /** Map raw Atom entries to normalized UserInfo */
790
+ function toUserInfoList(entries) {
791
+ return entries.map((e) => ({
792
+ username: e.id ?? "",
793
+ fullName: e.title,
794
+ uri: e.link?.[0]?.href
795
+ }));
796
+ }
797
+ /**
798
+ * User Service - orchestrates user lookup operations
799
+ */
800
+ var UserService = class {
801
+ constructor(adt) {
802
+ this.adt = adt;
803
+ }
804
+ /** Get the currently authenticated user via systeminformation */
805
+ async getCurrentUser() {
806
+ const sysInfo = await this.adt.core.http.systeminformation.getSystemInfo();
807
+ return {
808
+ userName: sysInfo.userName ?? "",
809
+ userFullName: sysInfo.userFullName,
810
+ systemID: sysInfo.systemID ?? "",
811
+ client: sysInfo.client ?? ""
812
+ };
813
+ }
814
+ /** Look up a specific user by exact username */
815
+ async getUserByName(username) {
816
+ const normalized = username.trim().toUpperCase();
817
+ if (!normalized) throw new Error("username is required");
818
+ return toUserInfoList(extractEntries(await this.adt.system.users.get(normalized)));
819
+ }
820
+ /** Search users by wildcard query */
821
+ async searchUsers(query, maxcount = 50) {
822
+ const normalizedQuery = query.trim();
823
+ if (!normalizedQuery) throw new Error("query is required");
824
+ if (!Number.isInteger(maxcount) || maxcount <= 0) throw new Error("maxcount must be a positive integer");
825
+ return toUserInfoList(extractEntries(await this.adt.system.users.search({
826
+ querystring: normalizedQuery,
827
+ maxcount
828
+ })));
829
+ }
830
+ };
831
+ function createUserService(adt) {
832
+ return new UserService(adt);
833
+ }
834
+
835
+ //#endregion
836
+ //#region src/client.ts
837
+ /**
838
+ * ADT Client V2 - Contract-Based Architecture
839
+ *
840
+ * Provides typed access to SAP ADT REST APIs:
841
+ * - client.adt.* - Typed ADT REST contracts (speci-generated)
842
+ * - client.fetch() - Generic HTTP utility for raw requests
843
+ *
844
+ * Note: Business logic (transport management, etc.) has moved to @abapify/adk
845
+ * Use AdkTransportRequest for transport operations.
846
+ */
847
+ function createAdtClient(config) {
848
+ const adapter = createAdtAdapter(config);
849
+ const adtClient = createAdtClient$1({
850
+ baseUrl: config.baseUrl,
851
+ adapter
852
+ });
853
+ const fetchFn = async (url, options) => {
854
+ const requestOptions = {
855
+ method: options?.method || "GET",
856
+ url,
857
+ headers: options?.headers || {},
858
+ body: options?.body
859
+ };
860
+ return adapter.request(requestOptions);
861
+ };
862
+ return {
863
+ adt: adtClient,
864
+ services: { transports: createTransportService(adtClient) },
865
+ fetch: fetchFn,
866
+ clearETag: (url) => adapter.clearETag(url)
867
+ };
868
+ }
869
+
870
+ //#endregion
871
+ //#region src/plugins/logging.ts
872
+ /**
873
+ * Logging plugin - logs requests and responses
874
+ */
875
+ var LoggingPlugin = class {
876
+ name = "logging";
877
+ constructor(logger = console.log) {
878
+ this.logger = logger;
879
+ }
880
+ process(context) {
881
+ this.logger(`[${context.method}] ${context.url}`, {
882
+ contentType: context.contentType,
883
+ hasParsedData: !!context.parsedData,
884
+ rawSize: context.rawText.length
885
+ });
886
+ return context.parsedData;
887
+ }
888
+ };
889
+
890
+ //#endregion
891
+ //#region src/plugins/file-logging.ts
892
+ /**
893
+ * File Logging Plugin - Saves HTTP responses to files
894
+ */
895
+ /**
896
+ * Plugin that logs HTTP responses to files
897
+ * Similar to v1's FileLogger functionality
898
+ */
899
+ var FileLoggingPlugin = class {
900
+ name = "file-logging";
901
+ constructor(config) {
902
+ this.config = config;
903
+ }
904
+ process(context) {
905
+ try {
906
+ const filePath = this.generateFilePath(context.url, context.method);
907
+ const fullPath = join(this.config.outputDir, filePath);
908
+ mkdirSync(dirname(fullPath), { recursive: true });
909
+ writeFileSync(fullPath, context.rawText, "utf8");
910
+ this.config.logger?.trace(`Wrote response to: ${fullPath}`);
911
+ if (this.config.writeMetadata) {
912
+ const metadataPath = this.getMetadataPath(fullPath);
913
+ const metadata = {
914
+ url: context.url,
915
+ method: context.method,
916
+ contentType: context.contentType,
917
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
918
+ size: context.rawText.length
919
+ };
920
+ writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf8");
921
+ this.config.logger?.trace(`Wrote metadata to: ${metadataPath}`);
922
+ }
923
+ } catch (error) {
924
+ this.config.logger?.error(`Failed to write response file: ${error instanceof Error ? error.message : String(error)}`);
925
+ }
926
+ return context.parsedData;
927
+ }
928
+ /**
929
+ * Generate file path for HTTP response
930
+ * Converts URL to fixture-style path structure
931
+ */
932
+ generateFilePath(url, method) {
933
+ const urlObj = new URL(url);
934
+ let path = urlObj.pathname;
935
+ path = path.replace(/^\/sap\/bc\/adt\/?/, "");
936
+ if (!path || path === "discovery") return "./adt/core/discovery.xml";
937
+ const [basePath] = path.split("?");
938
+ const segments = basePath.split("/").filter((s) => s);
939
+ const sourceTypes = [
940
+ "main",
941
+ "definitions",
942
+ "implementations",
943
+ "macros",
944
+ "testclasses"
945
+ ];
946
+ const lastSegment = segments[segments.length - 1];
947
+ if (sourceTypes.includes(lastSegment)) return `./adt/${segments.join("/")}`;
948
+ const requestId = Date.now().toString() + Math.random().toString(36).slice(2, 7);
949
+ let dirPath = `./adt/${segments.join("/")}`;
950
+ if (urlObj.search) {
951
+ const sanitizedQuery = urlObj.search.slice(1).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
952
+ dirPath += `/${sanitizedQuery}`;
953
+ }
954
+ const methodPrefix = method !== "GET" ? `${method.toLowerCase()}-` : "";
955
+ return `${dirPath}/${methodPrefix}${requestId}-response.xml`;
956
+ }
957
+ /**
958
+ * Get metadata file path for a response file
959
+ */
960
+ getMetadataPath(responsePath) {
961
+ if (/-response\.xml$/.test(responsePath)) return responsePath.replace(/-response\.xml$/, "-metadata.json");
962
+ if (/\.xml$/.test(responsePath)) return responsePath.replace(/\.xml$/, ".metadata.json");
963
+ return `${responsePath}.metadata.json`;
964
+ }
965
+ };
966
+
967
+ //#endregion
968
+ export { CookieStore, CsrfTokenManager, FileLoggingPlugin, LoggingPlugin, SessionManager, TransportService, UserService, adtContract, createAdtAdapter, createAdtClient, createTransportService, createUserService };
969
+ //# sourceMappingURL=index.mjs.map