abapgit-agent 1.17.8 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,84 @@
1
+ 'use strict';
2
+
1
3
  /**
2
- * ABAP HTTP request wrapper with CSRF token and session management
4
+ * ABAP HTTP client for the custom abapgit-agent REST handler.
5
+ *
6
+ * Uses axios with connection pooling (keepAlive). Handles CSRF tokens,
7
+ * cookie management, session caching, and automatic auth retry.
8
+ *
9
+ * Responses are parsed as JSON (the /sap/bc/z_abapgit_agent/ handler
10
+ * always returns JSON). ABAP-specific newline escaping is applied
11
+ * before parsing.
3
12
  */
13
+ const axios = require('axios');
4
14
  const https = require('https');
5
- const http = require('http');
6
- const { extractBodyDetail } = require('./format-error');
7
15
  const fs = require('fs');
8
16
  const path = require('path');
17
+ const { extractBodyDetail } = require('./format-error');
9
18
  const os = require('os');
10
19
  const crypto = require('crypto');
11
20
 
12
- /**
13
- * ABAP HTTP client with CSRF token, cookie, and session caching
14
- */
15
21
  class AbapHttp {
16
22
  constructor(config) {
17
23
  this.config = config;
18
24
  this.csrfToken = null;
19
25
  this.cookies = null;
20
26
 
27
+ const isHttp = config.protocol === 'http';
28
+ const baseURL = `${config.protocol || 'https'}://${config.host}:${config.sapport}`;
29
+ this._axios = axios.create({
30
+ baseURL,
31
+ ...(isHttp
32
+ ? { httpAgent: new (require('http').Agent)({ keepAlive: true }) }
33
+ : { httpsAgent: new https.Agent({ rejectUnauthorized: false, keepAlive: true }) }),
34
+ maxRedirects: 0,
35
+ validateStatus: () => true,
36
+ });
37
+
38
+ // Request interceptor: inject auth, CSRF, cookies, sap-client on every request
39
+ this._axios.interceptors.request.use((reqConfig) => {
40
+ reqConfig.headers['Authorization'] =
41
+ `Basic ${Buffer.from(`${config.user}:${config.password}`).toString('base64')}`;
42
+ reqConfig.headers['sap-client'] = config.client;
43
+ reqConfig.headers['sap-language'] = config.language || 'EN';
44
+ if (this.cookies) reqConfig.headers['Cookie'] = this.cookies;
45
+ if (['post', 'delete'].includes(reqConfig.method) && this.csrfToken) {
46
+ reqConfig.headers['X-CSRF-Token'] = this.csrfToken;
47
+ }
48
+ return reqConfig;
49
+ });
50
+
51
+ // Response interceptor: merge Set-Cookie headers into cookie jar.
52
+ // Skip on error responses (>= 400) to avoid stale cookie overwrites.
53
+ this._axios.interceptors.response.use((resp) => {
54
+ if (resp.status >= 400) return resp;
55
+ const setCookie = resp.headers['set-cookie'];
56
+ if (setCookie) {
57
+ const incoming = Array.isArray(setCookie)
58
+ ? setCookie.map(c => c.split(';')[0])
59
+ : [setCookie.split(';')[0]];
60
+ const jar = new Map();
61
+ if (this.cookies) {
62
+ this.cookies.split(';').forEach(pair => {
63
+ const trimmed = pair.trim();
64
+ if (trimmed) {
65
+ const eq = trimmed.indexOf('=');
66
+ jar.set(eq === -1 ? trimmed : trimmed.slice(0, eq).trim(), trimmed);
67
+ }
68
+ });
69
+ }
70
+ incoming.forEach(pair => {
71
+ const trimmed = pair.trim();
72
+ if (trimmed) {
73
+ const eq = trimmed.indexOf('=');
74
+ jar.set(eq === -1 ? trimmed : trimmed.slice(0, eq).trim(), trimmed);
75
+ }
76
+ });
77
+ this.cookies = [...jar.values()].join('; ');
78
+ }
79
+ return resp;
80
+ });
81
+
21
82
  // Session cache file path
22
83
  const configHash = crypto.createHash('md5')
23
84
  .update(`${config.host}:${config.user}:${config.client}`)
@@ -25,48 +86,27 @@ class AbapHttp {
25
86
  .substring(0, 8);
26
87
 
27
88
  this.sessionFile = path.join(os.tmpdir(), `abapgit-session-${configHash}.json`);
28
-
29
- // Try to load cached session
30
89
  this.loadSession();
31
90
  }
32
91
 
33
- /**
34
- * Load session from cache file if valid
35
- */
36
92
  loadSession() {
37
- if (!fs.existsSync(this.sessionFile)) {
38
- return;
39
- }
40
-
93
+ if (!fs.existsSync(this.sessionFile)) return;
41
94
  try {
42
95
  const session = JSON.parse(fs.readFileSync(this.sessionFile, 'utf8'));
43
-
44
- // Check if expired (with 2-minute safety margin)
45
- const now = Date.now();
46
- const safetyMargin = 2 * 60 * 1000; // 2 minutes
47
-
48
- if (session.expiresAt > now + safetyMargin) {
96
+ const safetyMargin = 2 * 60 * 1000;
97
+ if (session.expiresAt > Date.now() + safetyMargin) {
49
98
  this.csrfToken = session.csrfToken;
50
99
  this.cookies = session.cookies;
51
- // Silent - no console output for cached session
52
100
  } else {
53
- // Session expired
54
101
  this.clearSession();
55
102
  }
56
103
  } catch (e) {
57
- // Corrupted cache file - clear it
58
104
  this.clearSession();
59
105
  }
60
106
  }
61
107
 
62
- /**
63
- * Save session to cache file
64
- */
65
108
  saveSession() {
66
- // Conservative expiration: 15 minutes
67
- // (ABAP default session timeout is typically 20 minutes)
68
109
  const expiresAt = Date.now() + (15 * 60 * 1000);
69
-
70
110
  try {
71
111
  fs.writeFileSync(this.sessionFile, JSON.stringify({
72
112
  csrfToken: this.csrfToken,
@@ -75,37 +115,27 @@ class AbapHttp {
75
115
  savedAt: Date.now()
76
116
  }));
77
117
  } catch (e) {
78
- // Ignore write errors - session caching is optional
118
+ // Ignore write errors
79
119
  }
80
120
  }
81
121
 
82
- /**
83
- * Clear cached session
84
- */
85
122
  clearSession() {
86
123
  this.csrfToken = null;
87
124
  this.cookies = null;
88
-
89
125
  try {
90
- if (fs.existsSync(this.sessionFile)) {
91
- fs.unlinkSync(this.sessionFile);
92
- }
126
+ if (fs.existsSync(this.sessionFile)) fs.unlinkSync(this.sessionFile);
93
127
  } catch (e) {
94
- // Ignore file deletion errors
128
+ // Ignore deletion errors
95
129
  }
96
130
  }
97
131
 
98
132
  /**
99
- * Detect if error is due to expired/invalid session
100
- * @param {Error|object} error - Error object or response
101
- * @returns {boolean} True if auth error
133
+ * Detect if error is due to expired/invalid session.
102
134
  */
103
135
  isAuthError(error) {
104
- // HTTP status codes
105
- if (error.statusCode === 401) return true; // Unauthorized
106
- if (error.statusCode === 403) return true; // Forbidden
136
+ if (error.statusCode === 401) return true;
137
+ if (error.statusCode === 403) return true;
107
138
 
108
- // Error message patterns
109
139
  const message = (error.message || error.error || '').toLowerCase();
110
140
  if (message.includes('csrf')) return true;
111
141
  if (message.includes('token')) return true;
@@ -114,7 +144,6 @@ class AbapHttp {
114
144
  if (message.includes('unauthorized')) return true;
115
145
  if (message.includes('forbidden')) return true;
116
146
 
117
- // Response body patterns
118
147
  if (error.body) {
119
148
  const bodyStr = JSON.stringify(error.body).toLowerCase();
120
149
  if (bodyStr.includes('csrf')) return true;
@@ -126,253 +155,108 @@ class AbapHttp {
126
155
  }
127
156
 
128
157
  /**
129
- * Fetch CSRF token using GET /health with X-CSRF-Token: fetch
130
- * @returns {Promise<string>} CSRF token
158
+ * Fetch CSRF token via GET /health with X-CSRF-Token: fetch
131
159
  */
132
160
  async fetchCsrfToken() {
133
- const url = new URL(`/sap/bc/z_abapgit_agent/health`, `${this.config.protocol || 'https'}://${this.config.host}:${this.config.sapport}`);
134
-
135
- return new Promise((resolve, reject) => {
136
- const options = {
137
- hostname: url.hostname,
138
- port: url.port,
139
- path: url.pathname,
140
- method: 'GET',
141
- headers: {
142
- 'Authorization': `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`,
143
- 'sap-client': this.config.client,
144
- 'sap-language': this.config.language || 'EN',
145
- 'X-CSRF-Token': 'fetch',
146
- 'Content-Type': 'application/json'
147
- },
148
- agent: this.config.protocol === 'http' ? undefined : new https.Agent({ rejectUnauthorized: false })
149
- };
150
-
151
- const req = (this.config.protocol === 'http' ? http : https).request(options, (res) => {
152
- const csrfToken = res.headers['x-csrf-token'];
153
-
154
- // Save cookies from response
155
- const setCookie = res.headers['set-cookie'];
156
- if (setCookie) {
157
- this.cookies = Array.isArray(setCookie)
158
- ? setCookie.map(c => c.split(';')[0]).join('; ')
159
- : setCookie.split(';')[0];
160
- }
161
-
162
- let body = '';
163
- res.on('data', chunk => body += chunk);
164
- res.on('end', () => {
165
- this.csrfToken = csrfToken;
166
-
167
- // Save session to cache
168
- this.saveSession();
169
-
170
- resolve(csrfToken);
171
- });
172
- });
173
-
174
- req.on('error', reject);
175
- req.end();
161
+ const resp = await this._axios.get('/sap/bc/z_abapgit_agent/health', {
162
+ headers: {
163
+ 'X-CSRF-Token': 'fetch',
164
+ 'Content-Type': 'application/json'
165
+ }
176
166
  });
167
+ this.csrfToken = resp.headers['x-csrf-token'] || this.csrfToken;
168
+ this.saveSession();
169
+ return this.csrfToken;
177
170
  }
178
171
 
179
172
  /**
180
- * Make HTTP request to ABAP REST endpoint with automatic retry on auth failure
181
- * @param {string} method - HTTP method (GET, POST, DELETE)
182
- * @param {string} urlPath - URL path
183
- * @param {object} data - Request body (for POST)
184
- * @param {object} options - Additional options (headers, csrfToken, isRetry)
185
- * @returns {Promise<object>} Response JSON
173
+ * Make HTTP request with automatic retry on auth failure.
174
+ * Returns parsed JSON.
186
175
  */
187
176
  async request(method, urlPath, data = null, options = {}) {
188
177
  try {
189
- // Try with current session (cached or fresh)
190
178
  return await this._makeRequest(method, urlPath, data, options);
191
-
192
179
  } catch (error) {
193
- // Check if it's an authentication/authorization failure
194
180
  if (this.isAuthError(error) && !options.isRetry) {
195
- // Session expired - refresh and retry once
196
- console.error('⚠️ Session expired, refreshing...');
197
-
198
- // Clear stale session
199
181
  this.clearSession();
200
-
201
- // Fetch fresh token/cookies
202
182
  await this.fetchCsrfToken();
203
-
204
- // Retry ONCE with fresh session
205
- return await this._makeRequest(method, urlPath, data, {
206
- ...options,
207
- isRetry: true // Prevent infinite loop
208
- });
183
+ return await this._makeRequest(method, urlPath, data, { ...options, isRetry: true });
209
184
  }
210
-
211
- // Not an auth error or already retried - propagate
212
185
  throw error;
213
186
  }
214
187
  }
215
188
 
216
189
  /**
217
- * Internal request implementation (no retry logic)
218
- * @param {string} method - HTTP method
219
- * @param {string} urlPath - URL path
220
- * @param {object} data - Request body
221
- * @param {object} options - Additional options
222
- * @returns {Promise<object>} Response JSON
190
+ * Internal request returns parsed JSON.
223
191
  */
224
192
  async _makeRequest(method, urlPath, data = null, options = {}) {
225
- return new Promise((resolve, reject) => {
226
- const url = new URL(urlPath, `${this.config.protocol || 'https'}://${this.config.host}:${this.config.sapport}`);
227
-
228
- const headers = {
229
- 'Content-Type': 'application/json',
230
- 'sap-client': this.config.client,
231
- 'sap-language': this.config.language || 'EN',
232
- ...options.headers
233
- };
193
+ const headers = {
194
+ 'Content-Type': 'application/json',
195
+ ...options.headers
196
+ };
197
+
198
+ const resp = await this._axios.request({
199
+ method,
200
+ url: urlPath,
201
+ data: data ? JSON.stringify(data) : undefined,
202
+ headers,
203
+ responseType: 'text',
204
+ timeout: 120000,
205
+ });
234
206
 
235
- // Add authorization
236
- headers['Authorization'] = `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`;
207
+ const statusCode = resp.status;
237
208
 
238
- // Add CSRF token for POST
239
- if (method === 'POST' && options.csrfToken) {
240
- headers['X-CSRF-Token'] = options.csrfToken;
241
- }
209
+ if (statusCode === 401 || statusCode === 403) {
210
+ throw { statusCode, message: `Authentication failed: ${statusCode}`, isAuthError: true };
211
+ }
242
212
 
243
- // Add cookies if available
244
- if (this.cookies) {
245
- headers['Cookie'] = this.cookies;
246
- }
213
+ if (statusCode >= 400) {
214
+ const detail = extractBodyDetail(resp.data || '');
215
+ const message = detail
216
+ ? `(HTTP ${statusCode}) ${detail}`
217
+ : `(HTTP ${statusCode}) ${resp.statusText || 'Internal Server Error'}`;
218
+ throw { statusCode, message, body: resp.data || '' };
219
+ }
247
220
 
248
- const reqOptions = {
249
- hostname: url.hostname,
250
- port: url.port,
251
- path: url.pathname + url.search,
252
- method,
253
- headers,
254
- agent: this.config.protocol === 'http' ? undefined : new https.Agent({ rejectUnauthorized: false })
255
- };
221
+ // Parse JSON response — handle ABAP unescaped newlines
222
+ const body = resp.data || '';
223
+ try {
224
+ const cleanedBody = body.replace(/\n/g, '\\n');
225
+ const parsed = JSON.parse(cleanedBody);
256
226
 
257
- const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (res) => {
258
- // Check for auth errors
259
- if (res.statusCode === 401 || res.statusCode === 403) {
260
- reject({
261
- statusCode: res.statusCode,
262
- message: `Authentication failed: ${res.statusCode}`,
263
- isAuthError: true
264
- });
265
- return;
266
- }
227
+ // Check for CSRF/session errors in response body
228
+ if (this.isAuthError(parsed)) {
229
+ throw { statusCode, message: 'CSRF token or session error', body: parsed, isAuthError: true };
230
+ }
267
231
 
268
- // Check for other HTTP errors (4xx, 5xx)
269
- if (res.statusCode >= 400) {
270
- let body = '';
271
- res.on('data', chunk => body += chunk);
272
- res.on('end', () => {
273
- const detail = extractBodyDetail(body);
274
- const message = detail
275
- ? `(HTTP ${res.statusCode}) ${detail}`
276
- : `(HTTP ${res.statusCode}) ${res.statusMessage || 'Internal Server Error'}`;
277
- reject({
278
- statusCode: res.statusCode,
279
- message,
280
- body: body
281
- });
282
- });
283
- return;
232
+ return parsed;
233
+ } catch (e) {
234
+ if (e.isAuthError) throw e;
235
+ // Fallback: try to extract JSON from response
236
+ const jsonMatch = body.match(/\{[\s\S]*\}/);
237
+ if (jsonMatch) {
238
+ try {
239
+ const parsed = JSON.parse(jsonMatch[0].replace(/\n/g, '\\n'));
240
+ return parsed;
241
+ } catch (e2) {
242
+ throw { statusCode, message: 'Failed to parse JSON response', body, error: e2.message };
284
243
  }
285
-
286
- let body = '';
287
- res.on('data', chunk => body += chunk);
288
- res.on('end', () => {
289
- try {
290
- // Handle unescaped newlines from ABAP - replace actual newlines with \n
291
- const cleanedBody = body.replace(/\n/g, '\\n');
292
- const parsed = JSON.parse(cleanedBody);
293
-
294
- // Check for CSRF/session errors in response body
295
- if (this.isAuthError(parsed)) {
296
- reject({
297
- statusCode: res.statusCode,
298
- message: 'CSRF token or session error',
299
- body: parsed,
300
- isAuthError: true
301
- });
302
- return;
303
- }
304
-
305
- resolve(parsed);
306
- } catch (e) {
307
- // Fallback: try to extract JSON from response
308
- const jsonMatch = body.match(/\{[\s\S]*\}/);
309
- if (jsonMatch) {
310
- try {
311
- const parsed = JSON.parse(jsonMatch[0].replace(/\n/g, '\\n'));
312
- resolve(parsed);
313
- } catch (e2) {
314
- // JSON parse failed - reject instead of resolve
315
- reject({
316
- statusCode: res.statusCode,
317
- message: 'Failed to parse JSON response',
318
- body: body,
319
- error: e2.message
320
- });
321
- }
322
- } else {
323
- // No JSON found in body - reject instead of resolve
324
- reject({
325
- statusCode: res.statusCode,
326
- message: 'Invalid response format (not JSON)',
327
- body: body,
328
- error: e.message
329
- });
330
- }
331
- }
332
- });
333
- });
334
-
335
- req.on('error', reject);
336
-
337
- if (data) {
338
- req.write(JSON.stringify(data));
339
244
  }
340
- req.end();
341
- });
245
+ throw { statusCode, message: 'Invalid response format (not JSON)', body, error: e.message };
246
+ }
342
247
  }
343
248
 
344
- /**
345
- * Convenience method for GET requests
346
- * @param {string} urlPath - URL path
347
- * @param {object} options - Additional options
348
- * @returns {Promise<object>} Response JSON
349
- */
350
249
  async get(urlPath, options = {}) {
351
250
  return this.request('GET', urlPath, null, options);
352
251
  }
353
252
 
354
- /**
355
- * Convenience method for POST requests
356
- * @param {string} urlPath - URL path
357
- * @param {object} data - Request body
358
- * @param {object} options - Additional options (must include csrfToken)
359
- * @returns {Promise<object>} Response JSON
360
- */
361
253
  async post(urlPath, data, options = {}) {
362
254
  return this.request('POST', urlPath, data, options);
363
255
  }
364
256
 
365
- /**
366
- * Convenience method for DELETE requests
367
- * @param {string} urlPath - URL path
368
- * @param {object} options - Additional options
369
- * @returns {Promise<object>} Response JSON
370
- */
371
257
  async delete(urlPath, options = {}) {
372
258
  return this.request('DELETE', urlPath, null, options);
373
259
  }
374
260
  }
375
261
 
376
- module.exports = {
377
- AbapHttp
378
- };
262
+ module.exports = { AbapHttp };