@abapify/adt-proxy 0.0.0 → 0.4.5

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.
@@ -0,0 +1,92 @@
1
+ import { RouteDefinition, Serializable } from "@abapify/speci/rest";
2
+ //#region src/types.d.ts
3
+ /**
4
+ * Configuration for the ADT proxy server
5
+ */
6
+ interface AdtProxyConfig {
7
+ /** Port to listen on (default: 0 = random available port) */
8
+ port?: number;
9
+ /** Host to bind to (default: '127.0.0.1') */
10
+ host?: string;
11
+ /** Base URL of the downstream SAP system (e.g., 'https://sap-system.com:8000') */
12
+ targetUrl: string;
13
+ /** Authentication credentials for the downstream SAP system */
14
+ auth?: {
15
+ username: string;
16
+ password: string;
17
+ /** SAP client number (e.g., '100') */
18
+ client?: string;
19
+ };
20
+ /** Base path prefix to strip from incoming requests (default: '') */
21
+ basePath?: string;
22
+ /** Whether to forward all requests as-is when no schema match is found (default: true) */
23
+ forwardUnknown?: boolean;
24
+ /** Whether to convert JSON↔XML based on schema (default: true) */
25
+ convertContent?: boolean;
26
+ /** Custom headers to add to all downstream requests */
27
+ defaultHeaders?: Record<string, string>;
28
+ /** Maximum allowed request body size in bytes (default: 10MB) */
29
+ maxBodySize?: number;
30
+ /** Logger instance (must implement debug, info, warn, error methods) */
31
+ logger?: Logger;
32
+ }
33
+ /**
34
+ * Simple logger interface
35
+ */
36
+ interface Logger {
37
+ debug(msg: string, obj?: any): void;
38
+ info(msg: string, obj?: any): void;
39
+ warn(msg: string, obj?: any): void;
40
+ error(msg: string, obj?: any): void;
41
+ }
42
+ /**
43
+ * Result of a proxied request
44
+ */
45
+ interface ProxyResult {
46
+ /** HTTP status code from downstream */
47
+ status: number;
48
+ /** Response headers from downstream */
49
+ headers: Record<string, string | string[]>;
50
+ /** Response body (JSON string if converted, raw string otherwise) */
51
+ body: string;
52
+ /** Whether the response was converted from XML to JSON */
53
+ converted: boolean;
54
+ }
55
+ //#endregion
56
+ //#region src/converter.d.ts
57
+ /**
58
+ * Convert a JSON string to XML using a schema's build method.
59
+ */
60
+ declare function jsonToXml(json: string, schema: Serializable): string;
61
+ /**
62
+ * Convert an XML string to JSON using a schema's parse method.
63
+ */
64
+ declare function xmlToJson(xml: string, schema: Serializable): string;
65
+ /**
66
+ * Detect content type from a string or content-type header.
67
+ */
68
+ declare function detectContentType(content: string, contentType?: string): 'json' | 'xml' | 'text' | 'binary';
69
+ /**
70
+ * Check if a content type indicates JSON (case-insensitive).
71
+ */
72
+ declare function isJsonContentType(contentType: string): boolean;
73
+ /**
74
+ * Check if a content type indicates XML (case-insensitive).
75
+ */
76
+ declare function isXmlContentType(contentType: string): boolean;
77
+ //#endregion
78
+ //#region src/proxy.d.ts
79
+ declare function createAdtProxy(config: AdtProxyConfig): {
80
+ readonly routes: RouteDefinition[];
81
+ match: (method: string, url: string, basePath?: string) => {
82
+ route: RouteDefinition;
83
+ params: Record<string, string>;
84
+ } | null;
85
+ start(): Promise<{
86
+ port: number;
87
+ }>;
88
+ stop(): Promise<void>;
89
+ };
90
+ //#endregion
91
+ export { type AdtProxyConfig, type Logger, type ProxyResult, createAdtProxy, detectContentType, isJsonContentType, isXmlContentType, jsonToXml, xmlToJson };
92
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/converter.ts","../src/proxy.ts"],"mappings":";;;;;UASiB;;EAEf;;EAGA;;EAGA;;EAGA;IACE;IACA;;IAEA;;;EAIF;;EAGA;;EAGA;;EAGA,iBAAiB;;EAGjB;;EAGA,SAAS;;;;;UAMM;EACf,MAAM,aAAa;EACnB,KAAK,aAAa;EAClB,KAAK,aAAa;EAClB,MAAM,aAAa;;;;;UA0BJ;;EAEf;;EAGA,SAAS;;EAGT;;EAGA;;;;;;;iBC9Ec,UAAU,cAAc,QAAQ;;;;iBAehC,UAAU,aAAa,QAAQ;;;;iBAY/B,kBACd,iBACA;;;;iBAmBc,kBAAkB;;;;iBAQlB,iBAAiB;;;iBCuGjB,eAAe,QAAQ;;;;;;EAuQpB,SAAA;IAAU;;EAgCX,QAAA"}
package/dist/index.mjs ADDED
@@ -0,0 +1,325 @@
1
+ import { createServer } from "node:http";
2
+ import { createServer as createServer$1 } from "@abapify/speci/rest";
3
+ import { adtContract } from "@abapify/adt-contracts";
4
+
5
+ //#region src/converter.ts
6
+ /**
7
+ * Convert a JSON string to XML using a schema's build method.
8
+ */
9
+ function jsonToXml(json, schema) {
10
+ try {
11
+ const data = JSON.parse(json);
12
+ if (typeof schema.build === "function") return schema.build(data);
13
+ return json;
14
+ } catch {
15
+ return json;
16
+ }
17
+ }
18
+ /**
19
+ * Convert an XML string to JSON using a schema's parse method.
20
+ */
21
+ function xmlToJson(xml, schema) {
22
+ try {
23
+ const data = schema.parse(xml);
24
+ return JSON.stringify(data);
25
+ } catch {
26
+ return xml;
27
+ }
28
+ }
29
+ /**
30
+ * Detect content type from a string or content-type header.
31
+ */
32
+ function detectContentType(content, contentType) {
33
+ if (contentType) {
34
+ const ct = contentType.toLowerCase();
35
+ if (ct.includes("json")) return "json";
36
+ if (ct.includes("xml")) return "xml";
37
+ if (ct.includes("text")) return "text";
38
+ return "binary";
39
+ }
40
+ const trimmed = content.trimStart();
41
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json";
42
+ if (trimmed.startsWith("<?xml") || trimmed.startsWith("<")) return "xml";
43
+ return "text";
44
+ }
45
+ /**
46
+ * Check if a content type indicates JSON (case-insensitive).
47
+ */
48
+ function isJsonContentType(contentType) {
49
+ const ct = contentType.toLowerCase();
50
+ return ct.includes("json") || ct.endsWith("+json");
51
+ }
52
+ /**
53
+ * Check if a content type indicates XML (case-insensitive).
54
+ */
55
+ function isXmlContentType(contentType) {
56
+ const ct = contentType.toLowerCase();
57
+ return ct.includes("xml") || ct.endsWith("+xml");
58
+ }
59
+
60
+ //#endregion
61
+ //#region src/proxy.ts
62
+ /**
63
+ * ADT Proxy Server
64
+ *
65
+ * Proxies ADT endpoint requests to a downstream SAP system with
66
+ * automatic JSON↔XML conversion using contract schemas.
67
+ *
68
+ * Architecture:
69
+ * Client (JSON) → Proxy → Downstream SAP (XML)
70
+ * Client (JSON) ← Proxy ← Downstream SAP (XML)
71
+ *
72
+ * The proxy uses speci's createServer to extract route definitions
73
+ * from ADT contracts, enabling schema-aware content conversion.
74
+ *
75
+ * @example
76
+ * ```typescript
77
+ * import { createAdtProxy } from '@abapify/adt-proxy';
78
+ *
79
+ * const proxy = createAdtProxy({
80
+ * targetUrl: 'https://my-sap-system.com:8000',
81
+ * auth: { username: 'user', password: 'pass', client: '100' },
82
+ * });
83
+ *
84
+ * const { port } = await proxy.start();
85
+ * console.log(`Proxy running on http://localhost:${port}`);
86
+ * ```
87
+ */
88
+ const DEFAULT_LOGGER = {
89
+ debug: (_msg) => {},
90
+ info: (_msg) => {},
91
+ warn: console.warn,
92
+ error: console.error
93
+ };
94
+ const FORWARDABLE_REQUEST_HEADERS = [
95
+ "accept",
96
+ "content-type",
97
+ "x-csrf-token",
98
+ "x-sap-security-session",
99
+ "x-sap-adt-sessiontype",
100
+ "cookie"
101
+ ];
102
+ const FORWARDABLE_RESPONSE_HEADERS = [
103
+ "content-type",
104
+ "x-csrf-token",
105
+ "x-sap-security-session",
106
+ "set-cookie",
107
+ "etag",
108
+ "location"
109
+ ];
110
+ function forwardHeaders(source, target, keys) {
111
+ for (const h of keys) {
112
+ const value = source[h];
113
+ if (value && !target[h]) target[h] = value;
114
+ }
115
+ }
116
+ function addAuthHeaders(headers, auth) {
117
+ if (!auth) return;
118
+ if (!headers.authorization) headers.authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString("base64")}`;
119
+ if (auth.client && !headers["sap-client"]) headers["sap-client"] = auth.client;
120
+ }
121
+ function convertRequestBody(body, contentType, bodySchema) {
122
+ if (!body || !bodySchema || !contentType) return {
123
+ body,
124
+ converted: false
125
+ };
126
+ if (!isJsonContentType(contentType)) return {
127
+ body,
128
+ converted: false
129
+ };
130
+ return {
131
+ body: jsonToXml(body, bodySchema),
132
+ converted: true
133
+ };
134
+ }
135
+ function convertResponseBody(body, contentType, status, responseSchemas) {
136
+ if (!body || !isXmlContentType(contentType) || !responseSchemas[status]) return {
137
+ body,
138
+ converted: false
139
+ };
140
+ const converted = xmlToJson(body, responseSchemas[status]);
141
+ return {
142
+ body: converted,
143
+ converted: converted !== body
144
+ };
145
+ }
146
+ async function forwardRequest(method, url, headers, body, timeoutMs = 3e4) {
147
+ const controller = new AbortController();
148
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
149
+ try {
150
+ const response = await fetch(url, {
151
+ method,
152
+ headers,
153
+ body: body || void 0,
154
+ signal: controller.signal
155
+ });
156
+ const responseBody = await response.text();
157
+ const responseHeaders = {};
158
+ response.headers.forEach((value, key) => {
159
+ if (key.toLowerCase() === "set-cookie") return;
160
+ responseHeaders[key] = value;
161
+ });
162
+ if (typeof response.headers.getSetCookie === "function") {
163
+ const setCookies = response.headers.getSetCookie();
164
+ if (setCookies.length > 0) responseHeaders["set-cookie"] = setCookies;
165
+ }
166
+ return {
167
+ status: response.status,
168
+ headers: responseHeaders,
169
+ body: responseBody
170
+ };
171
+ } finally {
172
+ clearTimeout(timer);
173
+ }
174
+ }
175
+ function createAdtProxy(config) {
176
+ const { targetUrl, auth, basePath = "", forwardUnknown = true, convertContent = true, defaultHeaders = {}, logger = DEFAULT_LOGGER } = config;
177
+ const contractRoutes = createServer$1(adtContract);
178
+ let server;
179
+ function buildDownstreamHeaders(incomingHeaders, contractHeaders) {
180
+ const headers = {
181
+ ...defaultHeaders,
182
+ ...contractHeaders
183
+ };
184
+ forwardHeaders(incomingHeaders, headers, FORWARDABLE_REQUEST_HEADERS);
185
+ addAuthHeaders(headers, auth);
186
+ if (convertContent && !headers.accept) headers.accept = "application/json";
187
+ return headers;
188
+ }
189
+ function buildDownstreamUrl(url) {
190
+ const relativePath = basePath && url.startsWith(basePath) ? url.slice(basePath.length) || "/" : url;
191
+ return `${targetUrl}${relativePath}`;
192
+ }
193
+ function getContentType(headers) {
194
+ const ct = headers["content-type"];
195
+ return Array.isArray(ct) ? ct[0] : ct;
196
+ }
197
+ async function handleMatchedRoute(method, url, requestBody, incomingHeaders, route) {
198
+ logger.info(`Matched route: ${route.method} ${route.pathTemplate}`);
199
+ const { body: downstreamBody, converted: reqConverted } = convertRequestBody(requestBody, getContentType(incomingHeaders), route.bodySchema);
200
+ const downstreamHeaders = buildDownstreamHeaders(incomingHeaders, route.requestHeaders);
201
+ if (reqConverted) downstreamHeaders["content-type"] = "application/xml";
202
+ const response = await forwardRequest(method, buildDownstreamUrl(url), downstreamHeaders, downstreamBody);
203
+ const respContentType = Array.isArray(response.headers["content-type"]) ? response.headers["content-type"][0] : response.headers["content-type"] || "";
204
+ const { body: responseBody, converted: respConverted } = convertResponseBody(response.body, respContentType, response.status, route.responseSchemas);
205
+ return {
206
+ status: response.status,
207
+ headers: response.headers,
208
+ body: responseBody,
209
+ converted: respConverted
210
+ };
211
+ }
212
+ async function handleForwardedRequest(method, url, requestBody, incomingHeaders) {
213
+ logger.debug(`No route match for ${method} ${url} - forwarding as-is`);
214
+ const downstreamHeaders = buildDownstreamHeaders(incomingHeaders);
215
+ const response = await forwardRequest(method, buildDownstreamUrl(url), downstreamHeaders, requestBody);
216
+ return {
217
+ status: response.status,
218
+ headers: response.headers,
219
+ body: response.body,
220
+ converted: false
221
+ };
222
+ }
223
+ function sendNotFound(res, method, url) {
224
+ res.writeHead(404, { "Content-Type": "application/json" });
225
+ res.end(JSON.stringify({
226
+ error: "Not Found",
227
+ message: `No contract route matched for ${method} ${url}`,
228
+ availableRoutes: contractRoutes.routes.map((r) => ({
229
+ method: r.method,
230
+ path: r.pathTemplate
231
+ }))
232
+ }));
233
+ }
234
+ function sendResponse(res, result) {
235
+ const responseHeaders = { "x-proxy": "adt-proxy" };
236
+ forwardHeaders(result.headers, responseHeaders, FORWARDABLE_RESPONSE_HEADERS);
237
+ if (result.converted) responseHeaders["content-type"] = "application/json";
238
+ res.writeHead(result.status, responseHeaders);
239
+ res.end(result.body);
240
+ }
241
+ async function readBody(req, maxSize = 10485760) {
242
+ return new Promise((resolve, reject) => {
243
+ let size = 0;
244
+ const chunks = [];
245
+ req.on("data", (chunk) => {
246
+ size += chunk.length;
247
+ if (size > maxSize) {
248
+ reject(/* @__PURE__ */ new Error(`Request body exceeds ${maxSize} byte limit`));
249
+ return;
250
+ }
251
+ chunks.push(chunk);
252
+ });
253
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
254
+ req.on("error", (err) => reject(err));
255
+ });
256
+ }
257
+ async function handleRequest(req, res) {
258
+ const method = req.method || "GET";
259
+ const url = req.url || "/";
260
+ logger.info(`${method} ${url}`);
261
+ const requestBody = await readBody(req);
262
+ const match = contractRoutes.match(method, url, basePath);
263
+ if (match && convertContent) {
264
+ sendResponse(res, await handleMatchedRoute(method, url, requestBody, req.headers, match.route));
265
+ return;
266
+ }
267
+ if (match) {
268
+ const downstreamHeaders = buildDownstreamHeaders(req.headers, match.route.requestHeaders);
269
+ const response = await forwardRequest(method, buildDownstreamUrl(url), downstreamHeaders, requestBody);
270
+ sendResponse(res, {
271
+ status: response.status,
272
+ headers: response.headers,
273
+ body: response.body,
274
+ converted: false
275
+ });
276
+ return;
277
+ }
278
+ if (forwardUnknown) {
279
+ sendResponse(res, await handleForwardedRequest(method, url, requestBody, req.headers));
280
+ return;
281
+ }
282
+ sendNotFound(res, method, url);
283
+ }
284
+ return {
285
+ get routes() {
286
+ return contractRoutes.routes;
287
+ },
288
+ match: contractRoutes.match,
289
+ async start() {
290
+ return new Promise((resolve, reject) => {
291
+ server = createServer((req, res) => {
292
+ handleRequest(req, res).catch((err) => {
293
+ logger.error("Request handling error", { error: err.message });
294
+ res.writeHead(500, { "Content-Type": "application/json" });
295
+ res.end(JSON.stringify({
296
+ error: "Internal Server Error",
297
+ message: err.message
298
+ }));
299
+ });
300
+ });
301
+ server.on("error", reject);
302
+ server.listen(config.port || 0, config.host || "127.0.0.1", () => {
303
+ const addr = server?.address();
304
+ if (!addr || typeof addr !== "object") {
305
+ reject(/* @__PURE__ */ new Error("Failed to get server address"));
306
+ return;
307
+ }
308
+ logger.info(`ADT proxy listening on http://${config.host || "127.0.0.1"}:${addr.port}`);
309
+ logger.info(`Proxying to ${targetUrl}`);
310
+ resolve({ port: addr.port });
311
+ });
312
+ });
313
+ },
314
+ async stop() {
315
+ return new Promise((resolve, reject) => {
316
+ if (!server) return resolve();
317
+ server.close((err) => err ? reject(err) : resolve());
318
+ });
319
+ }
320
+ };
321
+ }
322
+
323
+ //#endregion
324
+ export { createAdtProxy, detectContentType, isJsonContentType, isXmlContentType, jsonToXml, xmlToJson };
325
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["createServer","httpCreateServer"],"sources":["../src/converter.ts","../src/proxy.ts"],"sourcesContent":["/**\n * ADT Proxy Server - Content Conversion\n *\n * Handles JSON↔XML conversion using schema parse/build methods.\n * This is the core of the proxy's content negotiation.\n */\n\nimport type { Serializable } from '@abapify/speci/rest';\n\n/**\n * Convert a JSON string to XML using a schema's build method.\n */\nexport function jsonToXml(json: string, schema: Serializable): string {\n try {\n const data = JSON.parse(json);\n if (typeof schema.build === 'function') {\n return schema.build(data);\n }\n return json;\n } catch {\n return json;\n }\n}\n\n/**\n * Convert an XML string to JSON using a schema's parse method.\n */\nexport function xmlToJson(xml: string, schema: Serializable): string {\n try {\n const data = schema.parse(xml);\n return JSON.stringify(data);\n } catch {\n return xml;\n }\n}\n\n/**\n * Detect content type from a string or content-type header.\n */\nexport function detectContentType(\n content: string,\n contentType?: string,\n): 'json' | 'xml' | 'text' | 'binary' {\n if (contentType) {\n const ct = contentType.toLowerCase();\n if (ct.includes('json')) return 'json';\n if (ct.includes('xml')) return 'xml';\n if (ct.includes('text')) return 'text';\n return 'binary';\n }\n\n const trimmed = content.trimStart();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 'json';\n if (trimmed.startsWith('<?xml') || trimmed.startsWith('<')) return 'xml';\n return 'text';\n}\n\n/**\n * Check if a content type indicates JSON (case-insensitive).\n */\nexport function isJsonContentType(contentType: string): boolean {\n const ct = contentType.toLowerCase();\n return ct.includes('json') || ct.endsWith('+json');\n}\n\n/**\n * Check if a content type indicates XML (case-insensitive).\n */\nexport function isXmlContentType(contentType: string): boolean {\n const ct = contentType.toLowerCase();\n return ct.includes('xml') || ct.endsWith('+xml');\n}\n","/**\n * ADT Proxy Server\n *\n * Proxies ADT endpoint requests to a downstream SAP system with\n * automatic JSON↔XML conversion using contract schemas.\n *\n * Architecture:\n * Client (JSON) → Proxy → Downstream SAP (XML)\n * Client (JSON) ← Proxy ← Downstream SAP (XML)\n *\n * The proxy uses speci's createServer to extract route definitions\n * from ADT contracts, enabling schema-aware content conversion.\n *\n * @example\n * ```typescript\n * import { createAdtProxy } from '@abapify/adt-proxy';\n *\n * const proxy = createAdtProxy({\n * targetUrl: 'https://my-sap-system.com:8000',\n * auth: { username: 'user', password: 'pass', client: '100' },\n * });\n *\n * const { port } = await proxy.start();\n * console.log(`Proxy running on http://localhost:${port}`);\n * ```\n */\n\nimport { createServer as httpCreateServer, type Server } from 'node:http';\nimport { createServer } from '@abapify/speci/rest';\nimport { adtContract, type AdtContract } from '@abapify/adt-contracts';\nimport type { RouteDefinition } from '@abapify/speci/rest';\nimport {\n jsonToXml,\n xmlToJson,\n isJsonContentType,\n isXmlContentType,\n} from './converter';\nimport type { AdtProxyConfig, ProxyResult, Logger } from './types';\n\nconst DEFAULT_LOGGER: Logger = {\n debug: (_msg: string) => {\n /* noop */\n },\n info: (_msg: string) => {\n /* noop */\n },\n warn: console.warn,\n error: console.error,\n};\n\nconst FORWARDABLE_REQUEST_HEADERS = [\n 'accept',\n 'content-type',\n 'x-csrf-token',\n 'x-sap-security-session',\n 'x-sap-adt-sessiontype',\n 'cookie',\n];\n\nconst FORWARDABLE_RESPONSE_HEADERS = [\n 'content-type',\n 'x-csrf-token',\n 'x-sap-security-session',\n 'set-cookie',\n 'etag',\n 'location',\n];\n\nfunction forwardHeaders(\n source: Record<string, string | string[] | undefined>,\n target: Record<string, string | string[]>,\n keys: string[],\n): void {\n for (const h of keys) {\n const value = source[h];\n if (value && !target[h]) {\n target[h] = value;\n }\n }\n}\n\nfunction addAuthHeaders(\n headers: Record<string, string>,\n auth?: AdtProxyConfig['auth'],\n): void {\n if (!auth) return;\n if (!headers.authorization) {\n const creds = Buffer.from(`${auth.username}:${auth.password}`).toString(\n 'base64',\n );\n headers.authorization = `Basic ${creds}`;\n }\n if (auth.client && !headers['sap-client']) {\n headers['sap-client'] = auth.client;\n }\n}\n\nfunction convertRequestBody(\n body: string,\n contentType: string | undefined,\n bodySchema?: RouteDefinition['bodySchema'],\n): { body: string; converted: boolean } {\n if (!body || !bodySchema || !contentType) {\n return { body, converted: false };\n }\n if (!isJsonContentType(contentType)) {\n return { body, converted: false };\n }\n return { body: jsonToXml(body, bodySchema), converted: true };\n}\n\nfunction convertResponseBody(\n body: string,\n contentType: string,\n status: number,\n responseSchemas: RouteDefinition['responseSchemas'],\n): { body: string; converted: boolean } {\n if (!body || !isXmlContentType(contentType) || !responseSchemas[status]) {\n return { body, converted: false };\n }\n const converted = xmlToJson(body, responseSchemas[status]);\n const wasConverted = converted !== body;\n return { body: converted, converted: wasConverted };\n}\n\nasync function forwardRequest(\n method: string,\n url: string,\n headers: Record<string, string>,\n body?: string,\n timeoutMs = 30_000,\n): Promise<{\n status: number;\n headers: Record<string, string | string[]>;\n body: string;\n}> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n const response = await fetch(url, {\n method,\n headers,\n body: body || undefined,\n signal: controller.signal,\n });\n\n const responseBody = await response.text();\n const responseHeaders: Record<string, string | string[]> = {};\n response.headers.forEach((value, key) => {\n if (key.toLowerCase() === 'set-cookie') return;\n responseHeaders[key] = value;\n });\n\n if (typeof response.headers.getSetCookie === 'function') {\n const setCookies = response.headers.getSetCookie();\n if (setCookies.length > 0) {\n responseHeaders['set-cookie'] = setCookies;\n }\n }\n\n return {\n status: response.status,\n headers: responseHeaders,\n body: responseBody,\n };\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport function createAdtProxy(config: AdtProxyConfig) {\n const {\n targetUrl,\n auth,\n basePath = '',\n forwardUnknown = true,\n convertContent = true,\n defaultHeaders = {},\n logger = DEFAULT_LOGGER,\n } = config;\n\n const contractRoutes = createServer(adtContract as AdtContract);\n let server: Server | undefined;\n\n function buildDownstreamHeaders(\n incomingHeaders: Record<string, string | string[] | undefined>,\n contractHeaders?: Record<string, string>,\n ): Record<string, string> {\n const headers: Record<string, string> = {\n ...defaultHeaders,\n ...contractHeaders,\n };\n forwardHeaders(incomingHeaders, headers, FORWARDABLE_REQUEST_HEADERS);\n addAuthHeaders(headers, auth);\n if (convertContent && !headers.accept) {\n headers.accept = 'application/json';\n }\n return headers;\n }\n\n function buildDownstreamUrl(url: string): string {\n const relativePath =\n basePath && url.startsWith(basePath)\n ? url.slice(basePath.length) || '/'\n : url;\n return `${targetUrl}${relativePath}`;\n }\n\n function getContentType(\n headers: Record<string, string | string[] | undefined>,\n ): string | undefined {\n const ct = headers['content-type'];\n return Array.isArray(ct) ? ct[0] : ct;\n }\n\n async function handleMatchedRoute(\n method: string,\n url: string,\n requestBody: string,\n incomingHeaders: Record<string, string | string[] | undefined>,\n route: RouteDefinition,\n ): Promise<ProxyResult> {\n logger.info(`Matched route: ${route.method} ${route.pathTemplate}`);\n\n const reqContentType = getContentType(incomingHeaders);\n const { body: downstreamBody, converted: reqConverted } =\n convertRequestBody(requestBody, reqContentType, route.bodySchema);\n\n const downstreamHeaders = buildDownstreamHeaders(\n incomingHeaders,\n route.requestHeaders,\n );\n if (reqConverted) {\n downstreamHeaders['content-type'] = 'application/xml';\n }\n\n const response = await forwardRequest(\n method,\n buildDownstreamUrl(url),\n downstreamHeaders,\n downstreamBody,\n );\n\n const respContentType = Array.isArray(response.headers['content-type'])\n ? response.headers['content-type'][0]\n : response.headers['content-type'] || '';\n const { body: responseBody, converted: respConverted } =\n convertResponseBody(\n response.body,\n respContentType,\n response.status,\n route.responseSchemas,\n );\n\n return {\n status: response.status,\n headers: response.headers,\n body: responseBody,\n converted: respConverted,\n };\n }\n\n async function handleForwardedRequest(\n method: string,\n url: string,\n requestBody: string,\n incomingHeaders: Record<string, string | string[] | undefined>,\n ): Promise<ProxyResult> {\n logger.debug(`No route match for ${method} ${url} - forwarding as-is`);\n\n const downstreamHeaders = buildDownstreamHeaders(incomingHeaders);\n const response = await forwardRequest(\n method,\n buildDownstreamUrl(url),\n downstreamHeaders,\n requestBody,\n );\n\n return {\n status: response.status,\n headers: response.headers,\n body: response.body,\n converted: false,\n };\n }\n\n function sendNotFound(\n res: {\n writeHead: (\n status: number,\n headers: Record<string, string | string[]>,\n ) => void;\n end: (body?: string) => void;\n },\n method: string,\n url: string,\n ): void {\n res.writeHead(404, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: 'Not Found',\n message: `No contract route matched for ${method} ${url}`,\n availableRoutes: contractRoutes.routes.map((r) => ({\n method: r.method,\n path: r.pathTemplate,\n })),\n }),\n );\n }\n\n function sendResponse(\n res: {\n writeHead: (\n status: number,\n headers: Record<string, string | string[]>,\n ) => void;\n end: (body?: string) => void;\n },\n result: ProxyResult,\n ): void {\n const responseHeaders: Record<string, string | string[]> = {\n 'x-proxy': 'adt-proxy',\n };\n forwardHeaders(\n result.headers,\n responseHeaders,\n FORWARDABLE_RESPONSE_HEADERS,\n );\n\n if (result.converted) {\n responseHeaders['content-type'] = 'application/json';\n }\n\n res.writeHead(result.status, responseHeaders);\n res.end(result.body);\n }\n\n async function readBody(\n req: {\n on: (event: string, cb: (...args: any[]) => void) => void;\n },\n maxSize = 10 * 1024 * 1024,\n ): Promise<string> {\n return new Promise((resolve, reject) => {\n let size = 0;\n const chunks: Buffer[] = [];\n req.on('data', (chunk: Buffer) => {\n size += chunk.length;\n if (size > maxSize) {\n reject(new Error(`Request body exceeds ${maxSize} byte limit`));\n return;\n }\n chunks.push(chunk);\n });\n req.on('end', () => resolve(Buffer.concat(chunks).toString()));\n req.on('error', (err: Error) => reject(err));\n });\n }\n\n async function handleRequest(\n req: {\n method?: string;\n url?: string;\n headers: Record<string, string | string[] | undefined>;\n on: (event: string, cb: (chunk: any) => void) => void;\n },\n res: {\n writeHead: (\n status: number,\n headers: Record<string, string | string[]>,\n ) => void;\n end: (body?: string) => void;\n },\n ): Promise<void> {\n const method = req.method || 'GET';\n const url = req.url || '/';\n logger.info(`${method} ${url}`);\n\n const requestBody = await readBody(req);\n const match = contractRoutes.match(method, url, basePath);\n\n if (match && convertContent) {\n const result = await handleMatchedRoute(\n method,\n url,\n requestBody,\n req.headers,\n match.route,\n );\n sendResponse(res, result);\n return;\n }\n\n if (match) {\n const downstreamHeaders = buildDownstreamHeaders(\n req.headers,\n match.route.requestHeaders,\n );\n const response = await forwardRequest(\n method,\n buildDownstreamUrl(url),\n downstreamHeaders,\n requestBody,\n );\n sendResponse(res, {\n status: response.status,\n headers: response.headers,\n body: response.body,\n converted: false,\n });\n return;\n }\n\n if (forwardUnknown) {\n const result = await handleForwardedRequest(\n method,\n url,\n requestBody,\n req.headers,\n );\n sendResponse(res, result);\n return;\n }\n\n sendNotFound(res, method, url);\n }\n\n return {\n get routes() {\n return contractRoutes.routes;\n },\n match: contractRoutes.match,\n\n async start(): Promise<{ port: number }> {\n return new Promise((resolve, reject) => {\n server = httpCreateServer((req, res) => {\n handleRequest(req, res).catch((err) => {\n logger.error('Request handling error', { error: err.message });\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: 'Internal Server Error',\n message: err.message,\n }),\n );\n });\n });\n\n server.on('error', reject);\n\n server.listen(config.port || 0, config.host || '127.0.0.1', () => {\n const addr = server?.address();\n if (!addr || typeof addr !== 'object') {\n reject(new Error('Failed to get server address'));\n return;\n }\n logger.info(\n `ADT proxy listening on http://${config.host || '127.0.0.1'}:${addr.port}`,\n );\n logger.info(`Proxying to ${targetUrl}`);\n resolve({ port: addr.port });\n });\n });\n },\n\n async stop(): Promise<void> {\n return new Promise((resolve, reject) => {\n if (!server) return resolve();\n server.close((err) => (err ? reject(err) : resolve()));\n });\n },\n };\n}\n\nexport { createServer } from '@abapify/speci/rest';\nexport type { AdtProxyConfig, ProxyResult, Logger } from './types';\nexport {\n jsonToXml,\n xmlToJson,\n detectContentType,\n isJsonContentType,\n isXmlContentType,\n} from './converter';\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1 +1,32 @@
1
- {"name":"@abapify/adt-proxy","version":"0.0.0","description":"Placeholder — real package is published via CI/CD (trusted publishing)."}
1
+ {
2
+ "name": "@abapify/adt-proxy",
3
+ "version": "0.4.5",
4
+ "description": "ADT proxy server with JSON↔XML conversion",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "types": "./dist/index.d.mts",
11
+ "exports": {
12
+ ".": "./dist/index.mjs",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "dependencies": {
20
+ "@abapify/adt-contracts": "0.4.5",
21
+ "@abapify/speci": "0.4.5"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/abapify/adt-cli.git",
26
+ "directory": "packages/adt-proxy"
27
+ },
28
+ "homepage": "https://github.com/abapify/adt-cli/tree/main/packages/adt-proxy#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/abapify/adt-cli/issues"
31
+ }
32
+ }
package/README.md DELETED
@@ -1 +0,0 @@
1
- # @abapify/adt-proxy