From f779b27755423591b105b6d36a2b483c05c18809 Mon Sep 17 00:00:00 2001 From: "Maple (gastown)" Date: Tue, 16 Jun 2026 23:22:11 +0000 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20add=20ADT=20proxy=20server=20with?= =?UTF-8?q?=20JSON=E2=86=94XML=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add speci createServer for extracting route definitions from REST contracts, and a new adt-proxy package that implements an ADT proxy server with automatic JSON↔XML conversion using contract schemas. The proxy forwards requests to a downstream SAP system, converting request bodies from JSON to XML and response bodies from XML to JSON using the schema parse/build methods. Also adds 'adt proxy' CLI command for easy proxy server startup. Changes: - speci: Add rest/server module with createServer() function - adt-proxy: New package implementing ADT proxy server - adt-cli: Add proxy command to CLI --- bun.lock | 1 + packages/adt-cli/package.json | 1 + packages/adt-cli/src/lib/cli.ts | 4 + packages/adt-cli/src/lib/commands/index.ts | 1 + packages/adt-cli/src/lib/commands/proxy.ts | 161 +++++++ packages/adt-proxy/package.json | 30 ++ packages/adt-proxy/project.json | 13 + packages/adt-proxy/src/converter.test.ts | 121 +++++ packages/adt-proxy/src/converter.ts | 82 ++++ packages/adt-proxy/src/index.ts | 30 ++ packages/adt-proxy/src/proxy.ts | 417 ++++++++++++++++++ packages/adt-proxy/src/types.ts | 89 ++++ packages/adt-proxy/tsconfig.json | 13 + packages/adt-proxy/tsdown.config.ts | 8 + packages/adt-proxy/vitest.config.ts | 8 + packages/speci/package.json | 1 + packages/speci/src/rest/index.ts | 3 + .../src/rest/server/create-server.test.ts | 160 +++++++ .../speci/src/rest/server/create-server.ts | 253 +++++++++++ packages/speci/src/rest/server/index.ts | 32 ++ packages/speci/src/rest/server/types.ts | 117 +++++ packages/speci/tsdown.config.ts | 2 +- 22 files changed, 1546 insertions(+), 1 deletion(-) create mode 100644 packages/adt-cli/src/lib/commands/proxy.ts create mode 100644 packages/adt-proxy/package.json create mode 100644 packages/adt-proxy/project.json create mode 100644 packages/adt-proxy/src/converter.test.ts create mode 100644 packages/adt-proxy/src/converter.ts create mode 100644 packages/adt-proxy/src/index.ts create mode 100644 packages/adt-proxy/src/proxy.ts create mode 100644 packages/adt-proxy/src/types.ts create mode 100644 packages/adt-proxy/tsconfig.json create mode 100644 packages/adt-proxy/tsdown.config.ts create mode 100644 packages/adt-proxy/vitest.config.ts create mode 100644 packages/speci/src/rest/server/create-server.test.ts create mode 100644 packages/speci/src/rest/server/create-server.ts create mode 100644 packages/speci/src/rest/server/index.ts create mode 100644 packages/speci/src/rest/server/types.ts diff --git a/bun.lock b/bun.lock index e62683da..aa625b0d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "abapify", diff --git a/packages/adt-cli/package.json b/packages/adt-cli/package.json index 1023496f..8bf51829 100644 --- a/packages/adt-cli/package.json +++ b/packages/adt-cli/package.json @@ -28,6 +28,7 @@ "@abapify/adt-plugin-abapgit": "0.4.1", "@abapify/adt-plugin-gcts": "0.4.1", "@abapify/adt-plugin-gcts-cli": "0.4.1", + "@abapify/adt-proxy": "0.4.1", "@abapify/adt-rfc": "0.4.1", "@abapify/adt-schemas": "0.4.1", "@abapify/adt-tui": "0.4.1", diff --git a/packages/adt-cli/src/lib/cli.ts b/packages/adt-cli/src/lib/cli.ts index 5b49b6b9..2ca2b46e 100644 --- a/packages/adt-cli/src/lib/cli.ts +++ b/packages/adt-cli/src/lib/cli.ts @@ -44,6 +44,7 @@ import { createChangesetCommand, rfcCommand, createFlpCommand, + proxyCommand, } from './commands'; import { createWbCommand } from './commands/wb'; import { createPackageCommand } from './commands/package'; @@ -326,6 +327,9 @@ export async function createCLI(options?: { // REPL - Interactive hypermedia navigator program.addCommand(createReplCommand()); + // Proxy command - ADT proxy server with JSON↔XML conversion + program.addCommand(proxyCommand); + // gCTS command-plugin (E07). Auto-registered here (not via adt.config.ts) // because `@abapify/adt-plugin-gcts-cli` is a required dependency of // `adt-cli`, matching the pattern used for the abapGit/gCTS *format* diff --git a/packages/adt-cli/src/lib/commands/index.ts b/packages/adt-cli/src/lib/commands/index.ts index 6f138af7..30b5683b 100644 --- a/packages/adt-cli/src/lib/commands/index.ts +++ b/packages/adt-cli/src/lib/commands/index.ts @@ -34,3 +34,4 @@ export { checkinCommand } from './checkin'; export { createChangesetCommand } from './changeset'; export { rfcCommand } from './rfc'; export { createFlpCommand } from './flp'; +export { proxyCommand } from './proxy'; diff --git a/packages/adt-cli/src/lib/commands/proxy.ts b/packages/adt-cli/src/lib/commands/proxy.ts new file mode 100644 index 00000000..9aa7bf18 --- /dev/null +++ b/packages/adt-cli/src/lib/commands/proxy.ts @@ -0,0 +1,161 @@ +import { Command } from 'commander'; +import { createAdtProxy } from '@abapify/adt-proxy'; +import { loadAuthSession, type AuthSession } from '../utils/auth'; + +export const proxyCommand = new Command('proxy') + .description('Start an ADT proxy server with JSON↔XML conversion') + .option( + '-p, --port ', + 'Port to listen on (default: random available port)', + parseInt, + ) + .option( + '-H, --host ', + 'Host to bind to (default: 127.0.0.1)', + '127.0.0.1', + ) + .option( + '-t, --target ', + 'Target SAP system URL (overrides current auth session)', + ) + .option( + '-b, --base-path ', + 'Base path prefix to strip from incoming requests', + '', + ) + .option( + '--no-convert', + 'Disable JSON↔XML conversion (forward requests as-is)', + ) + .option( + '--no-forward-unknown', + 'Return 404 for unmatched routes instead of forwarding', + ) + .option('--sid ', 'SAP System ID to use for authentication') + .action(async (options, _command) => { + try { + // Determine target URL and auth + let targetUrl: string | undefined = options.target; + let auth: + | { username: string; password: string; client?: string } + | undefined; + + if (!targetUrl) { + // Use current auth session + const session = loadAuthSession(options.sid); + if (!session) { + console.error('❌ Not authenticated and no --target specified'); + console.error( + '💡 Run "npx adt auth login" or provide --target ', + ); + process.exit(1); + } + + targetUrl = session.host; + + if (session.auth.method === 'basic') { + const creds = session.auth.credentials as { + username: string; + password: string; + }; + auth = { + username: creds.username, + password: creds.password, + client: session.client, + }; + } else if (session.auth.method === 'cookie') { + // Cookie auth - pass cookies as header + const creds = session.auth.credentials as { cookies: string }; + auth = undefined; // Will use cookie header instead + console.warn( + '⚠️ Cookie-based auth detected. Cookie will be forwarded to downstream.', + ); + } + } + + if (!targetUrl) { + console.error('❌ No target URL specified'); + process.exit(1); + } + + console.log('🔄 Starting ADT proxy server...\n'); + console.log(` Target: ${targetUrl}`); + console.log(` Port: ${options.port || 'auto'}`); + console.log(` Host: ${options.host}`); + console.log( + ` JSON↔XML conversion: ${options.convert !== false ? 'enabled' : 'disabled'}`, + ); + console.log( + ` Forward unknown routes: ${options.forwardUnknown !== false ? 'yes' : 'no'}`, + ); + console.log(''); + + const proxy = createAdtProxy({ + targetUrl, + auth, + port: options.port, + host: options.host, + basePath: options.basePath, + convertContent: options.convert !== false, + forwardUnknown: options.forwardUnknown !== false, + logger: { + debug: (msg, obj) => { + if (options.verbose) { + console.log(`[DEBUG] ${msg}`, obj || ''); + } + }, + info: (msg, obj) => { + console.log(`[INFO] ${msg}`, obj || ''); + }, + warn: (msg, obj) => { + console.warn(`[WARN] ${msg}`, obj || ''); + }, + error: (msg, obj) => { + console.error(`[ERROR] ${msg}`, obj || ''); + }, + }, + }); + + const { port } = await proxy.start(); + + console.log(`✅ ADT proxy running on http://${options.host}:${port}`); + console.log( + `\n📋 Available routes (${proxy.routes.length} from ADT contracts):`, + ); + for (const route of proxy.routes.slice(0, 10)) { + console.log(` ${route.method.padEnd(7)} ${route.pathTemplate}`); + } + if (proxy.routes.length > 10) { + console.log(` ... and ${proxy.routes.length - 10} more`); + } + console.log( + '\n💡 Send requests to the proxy and they will be forwarded to the target SAP system.', + ); + console.log( + ' JSON request bodies are automatically converted to XML for downstream requests.', + ); + console.log( + ' XML response bodies are automatically converted to JSON for proxy responses.', + ); + + // Handle graceful shutdown + const shutdown = async () => { + console.log('\n🔄 Shutting down proxy...'); + await proxy.stop(); + console.log('✅ Proxy stopped'); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + } catch (error) { + console.error( + '❌ Proxy failed to start:', + error instanceof Error ? error.message : String(error), + ); + if (error instanceof Error && error.stack) { + console.error('\nStack trace:', error.stack); + } + process.exit(1); + } + }); diff --git a/packages/adt-proxy/package.json b/packages/adt-proxy/package.json new file mode 100644 index 00000000..0345bc21 --- /dev/null +++ b/packages/adt-proxy/package.json @@ -0,0 +1,30 @@ +{ + "name": "@abapify/adt-proxy", + "version": "0.4.1", + "description": "ADT proxy server with JSON↔XML conversion", + "license": "MIT", + "type": "module", + "types": "./dist/index.d.mts", + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md" + ], + "dependencies": { + "@abapify/adt-contracts": "0.4.1", + "@abapify/speci": "0.4.1", + "fast-xml-parser": "^5.5.3" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/abapify/adt-cli.git", + "directory": "packages/adt-proxy" + }, + "homepage": "https://github.com/abapify/adt-cli/tree/main/packages/adt-proxy#readme", + "bugs": { + "url": "https://github.com/abapify/adt-cli/issues" + } +} diff --git a/packages/adt-proxy/project.json b/packages/adt-proxy/project.json new file mode 100644 index 00000000..704eb5d4 --- /dev/null +++ b/packages/adt-proxy/project.json @@ -0,0 +1,13 @@ +{ + "name": "adt-proxy", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/adt-proxy/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["{projectRoot}"] + } + }, + "tags": ["type:library", "scope:adt-proxy"], + "targets": {} +} diff --git a/packages/adt-proxy/src/converter.test.ts b/packages/adt-proxy/src/converter.test.ts new file mode 100644 index 00000000..888d1e47 --- /dev/null +++ b/packages/adt-proxy/src/converter.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { + jsonToXml, + xmlToJson, + detectContentType, + isJsonContentType, + isXmlContentType, +} from './converter'; + +// Mock schema with parse/build methods +const mockSchema = { + _infer: undefined as unknown as any, + parse: (_raw: string) => { + return { result: { value: 'hello' } }; + }, + build: (data: any) => { + return `${data.value}`; + }, +}; + +describe('converter', () => { + describe('jsonToXml', () => { + it('should convert JSON to XML using schema.build', () => { + const json = JSON.stringify({ value: 'hello' }); + const result = jsonToXml(json, mockSchema); + expect(result).toBe('hello'); + }); + + it('should return original string if JSON parsing fails', () => { + const result = jsonToXml('not json', mockSchema); + expect(result).toBe('not json'); + }); + + it('should return original string if schema has no build method', () => { + const schema = { parse: () => ({}), _infer: undefined }; + const json = JSON.stringify({ value: 'hello' }); + const result = jsonToXml(json, schema as any); + expect(result).toBe(json); + }); + }); + + describe('xmlToJson', () => { + it('should convert XML to JSON using schema.parse', () => { + const xml = 'hello'; + const result = xmlToJson(xml, mockSchema); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ result: { value: 'hello' } }); + }); + + it('should return original string if schema.parse fails', () => { + const badSchema = { + parse: () => { + throw new Error('parse error'); + }, + _infer: undefined, + }; + const result = xmlToJson('', badSchema as any); + expect(result).toBe(''); + }); + }); + + describe('detectContentType', () => { + it('should detect JSON from content-type header', () => { + expect(detectContentType('', 'application/json')).toBe('json'); + expect(detectContentType('', 'application/vnd.sap.adt.v1+json')).toBe( + 'json', + ); + }); + + it('should detect XML from content-type header', () => { + expect(detectContentType('', 'application/xml')).toBe('xml'); + expect(detectContentType('', 'application/vnd.sap.adt.v1+xml')).toBe( + 'xml', + ); + }); + + it('should detect text from content-type header', () => { + expect(detectContentType('', 'text/plain')).toBe('text'); + }); + + it('should detect JSON from content heuristics', () => { + expect(detectContentType('{"key": "value"}')).toBe('json'); + expect(detectContentType('[1, 2, 3]')).toBe('json'); + }); + + it('should detect XML from content heuristics', () => { + expect(detectContentType('')).toBe('xml'); + expect(detectContentType('')).toBe('xml'); + }); + + it('should detect text from content heuristics', () => { + expect(detectContentType('hello world')).toBe('text'); + }); + }); + + describe('isJsonContentType', () => { + it('should return true for JSON content types', () => { + expect(isJsonContentType('application/json')).toBe(true); + expect(isJsonContentType('application/vnd.sap+json')).toBe(true); + expect(isJsonContentType('text/json')).toBe(true); + }); + + it('should return false for non-JSON content types', () => { + expect(isJsonContentType('application/xml')).toBe(false); + expect(isJsonContentType('text/plain')).toBe(false); + }); + }); + + describe('isXmlContentType', () => { + it('should return true for XML content types', () => { + expect(isXmlContentType('application/xml')).toBe(true); + expect(isXmlContentType('application/vnd.sap+xml')).toBe(true); + expect(isXmlContentType('text/xml')).toBe(true); + }); + + it('should return false for non-XML content types', () => { + expect(isXmlContentType('application/json')).toBe(false); + expect(isXmlContentType('text/plain')).toBe(false); + }); + }); +}); diff --git a/packages/adt-proxy/src/converter.ts b/packages/adt-proxy/src/converter.ts new file mode 100644 index 00000000..799c8012 --- /dev/null +++ b/packages/adt-proxy/src/converter.ts @@ -0,0 +1,82 @@ +/** + * ADT Proxy Server - Content Conversion + * + * Handles JSON↔XML conversion using schema parse/build methods. + * This is the core of the proxy's content negotiation. + */ + +import type { Serializable } from '@abapify/speci/rest'; + +/** + * Convert a JSON string to XML using a schema's build method. + * + * @param json - The JSON string to convert + * @param schema - The schema with a build() method + * @returns The XML string, or the original JSON if conversion fails + */ +export function jsonToXml(json: string, schema: Serializable): string { + try { + const data = JSON.parse(json); + if (typeof schema.build === 'function') { + return schema.build(data); + } + // No build method - return as-is + return json; + } catch { + // If parsing fails, return the original string + return json; + } +} + +/** + * Convert an XML string to JSON using a schema's parse method. + * + * @param xml - The XML string to convert + * @param schema - The schema with a parse() method + * @returns The JSON string, or the original XML if conversion fails + */ +export function xmlToJson(xml: string, schema: Serializable): string { + try { + const data = schema.parse(xml); + return JSON.stringify(data); + } catch { + // If parsing fails, return the original string + return xml; + } +} + +/** + * Detect content type from a string or content-type header. + */ +export function detectContentType( + content: string, + contentType?: string, +): 'json' | 'xml' | 'text' | 'binary' { + // Check explicit content-type header first + if (contentType) { + if (contentType.includes('json')) return 'json'; + if (contentType.includes('xml')) return 'xml'; + if (contentType.includes('text')) return 'text'; + return 'binary'; + } + + // Heuristic detection + const trimmed = content.trimStart(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 'json'; + if (trimmed.startsWith(' {}, + info: () => {}, + warn: console.warn, + error: console.error, +}; + +/** + * Create an ADT proxy server. + * + * @param config - Proxy configuration + * @returns Proxy server instance with start/stop methods + */ +export function createAdtProxy(config: AdtProxyConfig) { + const { + targetUrl, + auth, + basePath = '', + forwardUnknown = true, + convertContent = true, + defaultHeaders = {}, + logger = DEFAULT_LOGGER, + } = config; + + // Extract routes from ADT contracts + const contractRoutes = createServer(adtContract as AdtContract); + let server: Server | undefined; + + /** + * Match an incoming request to a contract route. + */ + function matchRoute( + method: string, + url: string, + ): { route: RouteDefinition; params: Record } | null { + return contractRoutes.match(method, url, basePath); + } + + /** + * Build the downstream URL from the incoming request URL. + */ + function buildDownstreamUrl(url: string): string { + const relativePath = basePath + ? url.replace(new RegExp(`^${basePath}`), '') || '/' + : url; + return `${targetUrl}${relativePath}`; + } + + /** + * Build Basic Auth header from config. + */ + function getAuthHeader(): string | undefined { + if (!auth) return undefined; + const credentials = Buffer.from( + `${auth.username}:${auth.password}`, + ).toString('base64'); + return `Basic ${credentials}`; + } + + /** + * Build downstream request headers. + */ + function buildDownstreamHeaders( + incomingHeaders: Record, + contractHeaders?: Record, + isXmlBody = false, + ): Record { + const headers: Record = { + ...defaultHeaders, + ...contractHeaders, + }; + + // Forward relevant headers from the incoming request + const forwardableHeaders = [ + 'accept', + 'content-type', + 'x-csrf-token', + 'x-sap-security-session', + 'x-sap-adt-sessiontype', + 'cookie', + 'authorization', + ]; + + for (const h of forwardableHeaders) { + const value = incomingHeaders[h]; + if (value && !headers[h]) { + headers[h] = Array.isArray(value) ? value[0] : value; + } + } + + // Add auth if configured and not already present + if (auth && !headers.authorization) { + const authHeader = getAuthHeader(); + if (authHeader) headers.authorization = authHeader; + } + + // Add SAP client if configured + if (auth?.client && !headers['sap-client']) { + headers['sap-client'] = auth.client; + } + + // Set Accept to JSON for the proxy (we want JSON responses) + if (convertContent && !headers.accept) { + headers.accept = 'application/json'; + } + + return headers; + } + + /** + * Forward a request to the downstream SAP system. + */ + async function forwardRequest( + method: string, + url: string, + headers: Record, + body?: string, + ): Promise<{ + status: number; + headers: Record; + body: string; + }> { + const response = await fetch(url, { + method, + headers, + body: body || undefined, + }); + + const responseBody = await response.text(); + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + return { + status: response.status, + headers: responseHeaders, + body: responseBody, + }; + } + + /** + * Handle a single incoming HTTP request. + */ + async function handleRequest( + req: { + method?: string; + url?: string; + headers: Record; + on: (event: string, cb: (chunk: any) => void) => void; + }, + res: { + writeHead: (status: number, headers: Record) => void; + end: (body?: string) => void; + }, + ): Promise { + const method = req.method || 'GET'; + const url = req.url || '/'; + + logger.info(`${method} ${url}`); + + // Read request body + let requestBody = ''; + await new Promise((resolve) => { + req.on('data', (chunk: Buffer) => { + requestBody += chunk.toString(); + }); + req.on('end', () => resolve()); + }); + + // Match to a contract route + const match = matchRoute(method, url); + + let result: ProxyResult; + + if (match && convertContent) { + // Route matched - use schema-aware conversion + const { route, params } = match; + logger.info(`Matched route: ${route.method} ${route.pathTemplate}`, { + params, + }); + + // Convert JSON body → XML if needed + let downstreamBody = requestBody; + if (requestBody && route.bodySchema) { + const contentType = Array.isArray(req.headers['content-type']) + ? req.headers['content-type'][0] + : req.headers['content-type']; + + if (contentType && isJsonContentType(contentType)) { + downstreamBody = jsonToXml(requestBody, route.bodySchema); + logger.debug('Converted request body: JSON → XML'); + } + } + + // Build downstream headers + const downstreamHeaders = buildDownstreamHeaders( + req.headers, + route.requestHeaders, + downstreamBody !== requestBody, + ); + + // Set Content-Type for XML body + if (downstreamBody !== requestBody) { + downstreamHeaders['content-type'] = 'application/xml'; + } + + // Forward to downstream SAP + const downstreamUrl = buildDownstreamUrl(url); + const response = await forwardRequest( + method, + downstreamUrl, + downstreamHeaders, + downstreamBody, + ); + + // Convert XML response → JSON if needed + let responseBody = response.body; + let converted = false; + if (response.body && route.responseSchemas[200]) { + const respContentType = response.headers['content-type'] || ''; + if (isXmlContentType(respContentType)) { + responseBody = xmlToJson(response.body, route.responseSchemas[200]); + converted = true; + logger.debug('Converted response body: XML → JSON'); + } + } + + result = { + status: response.status, + headers: response.headers, + body: responseBody, + converted, + }; + } else if (forwardUnknown) { + // No route match - forward as-is + if (match === null) { + logger.debug(`No route match for ${method} ${url} - forwarding as-is`); + } + + const downstreamHeaders = buildDownstreamHeaders(req.headers); + const downstreamUrl = buildDownstreamUrl(url); + + const response = await forwardRequest( + method, + downstreamUrl, + downstreamHeaders, + requestBody, + ); + + result = { + status: response.status, + headers: response.headers, + body: response.body, + converted: false, + }; + } else { + // No route match and forwarding disabled + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'Not Found', + message: `No contract route matched for ${method} ${url}`, + availableRoutes: contractRoutes.routes.map((r) => ({ + method: r.method, + path: r.pathTemplate, + })), + }), + ); + return; + } + + // Send response + const responseHeaders: Record = { + 'x-proxy': 'adt-proxy', + }; + + // Forward relevant response headers + const forwardableResponseHeaders = [ + 'content-type', + 'x-csrf-token', + 'x-sap-security-session', + 'set-cookie', + 'etag', + 'location', + ]; + + for (const h of forwardableResponseHeaders) { + if (result.headers[h]) { + responseHeaders[h] = result.headers[h]; + } + } + + // Set content type to JSON if we converted + if (result.converted) { + responseHeaders['content-type'] = 'application/json'; + } + + res.writeHead(result.status, responseHeaders); + res.end(result.body); + } + + return { + /** + * Get the extracted route definitions (for inspection/testing). + */ + get routes() { + return contractRoutes.routes; + }, + + /** + * Match a request against the contract routes. + */ + match: contractRoutes.match, + + /** + * Start the proxy server. + * + * @returns The port the server is listening on + */ + async start(): Promise<{ port: number }> { + return new Promise((resolve, reject) => { + server = httpCreateServer((req, res) => { + handleRequest(req, res).catch((err) => { + logger.error('Request handling error', { error: err.message }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'Internal Server Error', + message: err.message, + }), + ); + }); + }); + + server.listen(config.port || 0, config.host || '127.0.0.1', () => { + const addr = server?.address(); + if (!addr || typeof addr !== 'object') { + reject(new Error('Failed to get server address')); + return; + } + logger.info( + `ADT proxy listening on http://${config.host || '127.0.0.1'}:${addr.port}`, + ); + logger.info(`Proxying to ${targetUrl}`); + resolve({ port: addr.port }); + }); + + server.on('error', reject); + }); + }, + + /** + * Stop the proxy server. + */ + async stop(): Promise { + return new Promise((resolve, reject) => { + if (!server) return resolve(); + server.close((err) => (err ? reject(err) : resolve())); + }); + }, + }; +} + +// Re-export types and utilities +export { createServer } from '@abapify/speci/rest'; +export type { AdtProxyConfig, ProxyResult, Logger } from './types'; +export { + jsonToXml, + xmlToJson, + detectContentType, + isJsonContentType, + isXmlContentType, +} from './converter'; diff --git a/packages/adt-proxy/src/types.ts b/packages/adt-proxy/src/types.ts new file mode 100644 index 00000000..5f3acad8 --- /dev/null +++ b/packages/adt-proxy/src/types.ts @@ -0,0 +1,89 @@ +/** + * ADT Proxy Server - Types + */ + +import type { Serializable } from '@abapify/speci/rest'; + +/** + * Configuration for the ADT proxy server + */ +export interface AdtProxyConfig { + /** Port to listen on (default: 0 = random available port) */ + port?: number; + + /** Host to bind to (default: '127.0.0.1') */ + host?: string; + + /** Base URL of the downstream SAP system (e.g., 'https://sap-system.com:8000') */ + targetUrl: string; + + /** Authentication credentials for the downstream SAP system */ + auth?: { + username: string; + password: string; + /** SAP client number (e.g., '100') */ + client?: string; + }; + + /** Base path prefix to strip from incoming requests (default: '') */ + basePath?: string; + + /** Whether to forward all requests as-is when no schema match is found (default: true) */ + forwardUnknown?: boolean; + + /** Whether to convert JSON↔XML based on schema (default: true) */ + convertContent?: boolean; + + /** Custom headers to add to all downstream requests */ + defaultHeaders?: Record; + + /** Logger instance (must implement debug, info, warn, error methods) */ + logger?: Logger; +} + +/** + * Simple logger interface + */ +export interface Logger { + debug(msg: string, obj?: any): void; + info(msg: string, obj?: any): void; + warn(msg: string, obj?: any): void; + error(msg: string, obj?: any): void; +} + +/** + * Proxy route handler context + */ +export interface ProxyRouteContext { + /** Matched route path template */ + pathTemplate: string; + + /** Extracted path parameters */ + params: Record; + + /** Request body schema (for JSON→XML conversion) */ + bodySchema?: Serializable; + + /** Response schemas (for XML→JSON conversion) */ + responseSchemas: Record; + + /** Original contract endpoint headers */ + contractHeaders?: Record; +} + +/** + * Result of a proxied request + */ +export interface ProxyResult { + /** HTTP status code from downstream */ + status: number; + + /** Response headers from downstream */ + headers: Record; + + /** Response body (JSON string if converted, raw string otherwise) */ + body: string; + + /** Whether the response was converted from XML to JSON */ + converted: boolean; +} diff --git a/packages/adt-proxy/tsconfig.json b/packages/adt-proxy/tsconfig.json new file mode 100644 index 00000000..f9b59ff6 --- /dev/null +++ b/packages/adt-proxy/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "composite": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"], + "references": [{ "path": "../speci" }, { "path": "../adt-contracts" }] +} diff --git a/packages/adt-proxy/tsdown.config.ts b/packages/adt-proxy/tsdown.config.ts new file mode 100644 index 00000000..7408dcc6 --- /dev/null +++ b/packages/adt-proxy/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsdown'; +import baseConfig from '../../tsdown.config.ts'; + +export default defineConfig({ + ...baseConfig, + entry: ['src/index.ts'], + tsconfig: 'tsconfig.json', +}); diff --git a/packages/adt-proxy/vitest.config.ts b/packages/adt-proxy/vitest.config.ts new file mode 100644 index 00000000..8e730d50 --- /dev/null +++ b/packages/adt-proxy/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); diff --git a/packages/speci/package.json b/packages/speci/package.json index e2a85fbf..3f2deb22 100644 --- a/packages/speci/package.json +++ b/packages/speci/package.json @@ -11,6 +11,7 @@ "exports": { ".": "./dist/index.mjs", "./rest": "./dist/rest/index.mjs", + "./rest/server": "./dist/rest/server/index.mjs", "./package.json": "./package.json" }, "files": [ diff --git a/packages/speci/src/rest/index.ts b/packages/speci/src/rest/index.ts index 796a191c..4c32dc0a 100644 --- a/packages/speci/src/rest/index.ts +++ b/packages/speci/src/rest/index.ts @@ -54,3 +54,6 @@ export { http, createHttp, type RestEndpointOptions } from './helpers'; // Export client export * from './client'; + +// Export server +export * from './server'; diff --git a/packages/speci/src/rest/server/create-server.test.ts b/packages/speci/src/rest/server/create-server.test.ts new file mode 100644 index 00000000..0fe23664 --- /dev/null +++ b/packages/speci/src/rest/server/create-server.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from 'vitest'; +import { createServer } from './create-server'; +import { createHttp } from '../helpers'; + +const http = createHttp(); + +// Simple test contract +const testContract = { + users: { + list: () => + http.get('/users', { + responses: { + 200: { + parse: (s: string) => JSON.parse(s), + _infer: undefined as unknown as any[], + }, + }, + }), + get: (id: string) => + http.get(`/users/${id}`, { + responses: { + 200: { + parse: (s: string) => JSON.parse(s), + _infer: undefined as unknown as any, + }, + }, + }), + create: (user: any) => + http.post('/users', { + body: { + parse: (s: string) => JSON.parse(s), + build: (d: any) => JSON.stringify(d), + _infer: undefined as unknown as any, + }, + responses: { + 201: { + parse: (s: string) => JSON.parse(s), + _infer: undefined as unknown as any, + }, + }, + }), + }, + posts: { + get: (userId: string, postId: string) => + http.get(`/users/${userId}/posts/${postId}`, { + responses: { + 200: { + parse: (s: string) => JSON.parse(s), + _infer: undefined as unknown as any, + }, + }, + }), + }, +}; + +describe('createServer', () => { + describe('route extraction', () => { + it('should extract routes from a nested contract', () => { + const server = createServer(testContract); + expect(server.routes).toHaveLength(4); + }); + + it('should extract correct HTTP methods', () => { + const server = createServer(testContract); + const methods = server.routes.map((r) => r.method); + expect(methods).toContain('GET'); + expect(methods).toContain('POST'); + }); + + it('should extract path parameter names', () => { + const server = createServer(testContract); + // Path with one param: /users/${id} → template /users/${p1} + const getRoute = server.routes.find( + (r) => r.pathTemplate === '/users/${p1}', + ); + expect(getRoute).toBeDefined(); + expect(getRoute!.pathParamNames).toEqual(['p1']); + }); + + it('should extract multiple path parameters', () => { + const server = createServer(testContract); + // Path with two params: /users/${userId}/posts/${postId} → template /users/${p1}/posts/${p2} + const postRoute = server.routes.find( + (r) => r.pathTemplate === '/users/${p1}/posts/${p2}', + ); + expect(postRoute).toBeDefined(); + expect(postRoute!.pathParamNames).toEqual(['p1', 'p2']); + }); + + it('should extract body schemas', () => { + const server = createServer(testContract); + const createRoute = server.routes.find( + (r) => r.pathTemplate === '/users' && r.method === 'POST', + ); + expect(createRoute).toBeDefined(); + expect(createRoute!.bodySchema).toBeDefined(); + expect(typeof createRoute!.bodySchema!.parse).toBe('function'); + expect(typeof createRoute!.bodySchema!.build).toBe('function'); + }); + + it('should extract response schemas', () => { + const server = createServer(testContract); + const listRoute = server.routes.find( + (r) => r.pathTemplate === '/users' && r.method === 'GET', + ); + expect(listRoute).toBeDefined(); + expect(listRoute!.responseSchemas[200]).toBeDefined(); + expect(typeof listRoute!.responseSchemas[200].parse).toBe('function'); + }); + }); + + describe('route matching', () => { + it('should match exact paths', () => { + const server = createServer(testContract); + const match = server.match('GET', '/users'); + expect(match).not.toBeNull(); + expect(match!.route.method).toBe('GET'); + expect(match!.route.pathTemplate).toBe('/users'); + }); + + it('should match paths with parameters', () => { + const server = createServer(testContract); + const match = server.match('GET', '/users/123'); + expect(match).not.toBeNull(); + expect(match!.params).toEqual({ p1: '123' }); + }); + + it('should match paths with multiple parameters', () => { + const server = createServer(testContract); + const match = server.match('GET', '/users/456/posts/789'); + expect(match).not.toBeNull(); + expect(match!.params).toEqual({ p1: '456', p2: '789' }); + }); + + it('should return null for unmatched paths', () => { + const server = createServer(testContract); + const match = server.match('GET', '/unknown'); + expect(match).toBeNull(); + }); + + it('should return null for wrong HTTP method', () => { + const server = createServer(testContract); + const match = server.match('DELETE', '/users'); + expect(match).toBeNull(); + }); + + it('should strip query strings', () => { + const server = createServer(testContract); + const match = server.match('GET', '/users?active=true'); + expect(match).not.toBeNull(); + }); + + it('should match with basePath prefix', () => { + const server = createServer(testContract); + const match = server.match('GET', '/api/v1/users/123', '/api/v1'); + expect(match).not.toBeNull(); + expect(match!.params).toEqual({ p1: '123' }); + }); + }); +}); diff --git a/packages/speci/src/rest/server/create-server.ts b/packages/speci/src/rest/server/create-server.ts new file mode 100644 index 00000000..480df52b --- /dev/null +++ b/packages/speci/src/rest/server/create-server.ts @@ -0,0 +1,253 @@ +/** + * Speci REST - Server Generator + * + * Creates server-ready route definitions from REST contracts. + * + * This is the server-side counterpart of createClient. It walks a contract + * tree, extracts endpoint definitions (method, path, body schema, response + * schemas), and returns structured route definitions that can be used to + * build HTTP servers with any framework (node:http, Express, Hono, etc.). + * + * @example + * ```typescript + * import { createServer } from '@abapify/speci/rest'; + * import { adtContract } from '@abapify/adt-contracts'; + * + * const server = createServer(adtContract); + * + * // Use with node:http + * import { createServer as httpCreate } from 'node:http'; + * + * httpCreate((req, res) => { + * const match = server.match(req.method!, req.url!); + * if (match) { + * // Forward to downstream SAP, handle JSON↔XML conversion, etc. + * } + * }); + * ``` + */ + +import type { OperationFunction } from '../../core/types'; +import type { RestEndpointDescriptor, Serializable } from '../types'; +import type { RouteDefinition, ServerRoutes } from './types'; + +/** Sentinel value used to mark parameter positions in resolved paths */ +const PARAM_MARKER = '\x00'; + +/** + * Check if a value is a RestEndpointDescriptor (has method and path) + */ +function isEndpointDescriptor(value: unknown): value is RestEndpointDescriptor { + return ( + typeof value === 'object' && + value !== null && + 'method' in value && + 'path' in value && + 'responses' in value + ); +} + +/** + * Check if a schema is Serializable (has parse method) + */ +function isSerializable(schema: unknown): schema is Serializable { + return ( + typeof schema === 'object' && + schema !== null && + typeof (schema as any).parse === 'function' + ); +} + +/** + * Walk a contract tree and extract all endpoint definitions. + * + * Calls each operation function with a sentinel value to get the resolved + * path, then replaces the sentinel with regex capture groups for matching. + */ +function walkContract( + obj: any, + parentPath: string[], +): Array<{ operation: OperationFunction; descriptor: RestEndpointDescriptor }> { + const endpoints: Array<{ + operation: OperationFunction; + descriptor: RestEndpointDescriptor; + }> = []; + + if (typeof obj === 'function') { + try { + // Call with sentinel values for all parameters to get the resolved path. + // The function's .length gives us the declared parameter count. + const paramCount = (obj as OperationFunction).length || 1; + const sentinelArgs = Array.from( + { length: paramCount }, + () => PARAM_MARKER, + ); + const descriptor = (obj as OperationFunction)(...sentinelArgs); + + if (isEndpointDescriptor(descriptor)) { + endpoints.push({ + operation: obj as OperationFunction, + descriptor, + }); + } + } catch { + // Some functions may need different args - skip + } + } else if (typeof obj === 'object' && obj !== null) { + for (const [key, value] of Object.entries(obj)) { + endpoints.push(...walkContract(value, [...parentPath, key])); + } + } + + return endpoints; +} + +/** + * Convert a resolved path with sentinel markers into a regex pattern. + * + * Also extracts path parameter names from the original function's parameter list. + * + * @example + * // For path '/users/\x00/posts/\x00' with param names ['userId', 'postId'] + * // Returns: /\/users\/([^/]+)\/posts\/([^/]+)/ + */ +function buildPathInfo( + resolvedPath: string, + paramCount: number, +): { regex: RegExp; template: string; paramNames: string[] } { + // Count sentinel occurrences to determine parameter count + const sentinelCount = ( + resolvedPath.match(new RegExp(PARAM_MARKER, 'g')) || [] + ).length; + + // Generate parameter names (p1, p2, ... if we don't know the real names) + const paramNames = Array.from( + { length: sentinelCount }, + (_, i) => `p${i + 1}`, + ); + + // Build regex by replacing sentinels with capture groups + const escaped = resolvedPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regexStr = escaped.replaceAll( + new RegExp(PARAM_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + '([^/]+)', + ); + + // Build template by replacing sentinels with ${paramName} syntax + let template = resolvedPath; + for (const name of paramNames) { + template = template.replace(PARAM_MARKER, `\${${name}}`); + } + + return { + regex: new RegExp(`^${regexStr}$`), + template, + paramNames, + }; +} + +/** + * Create server-ready route definitions from a REST contract. + * + * Walks the contract tree, extracts endpoint definitions, and returns + * structured route definitions with: + * - HTTP method and path + * - Body schema for request conversion (JSON→XML) + * - Response schemas for response conversion (XML→JSON) + * - Path parameter extraction + * + * @example + * ```typescript + * const server = createServer(adtContract); + * + * // Match an incoming request + * const match = server.match('GET', '/sap/bc/adt/cts/transportrequests/DEVK900001'); + * if (match) { + * console.log(match.route.method); // 'GET' + * console.log(match.params); // { p1: 'devk900001' } + * console.log(match.route.responseSchemas[200]); // Serializable schema + * } + * ``` + */ +export function createServer>( + contract: T, +): ServerRoutes { + const endpoints = walkContract(contract, []); + + const routes: RouteDefinition[] = endpoints.map( + ({ operation, descriptor }) => { + const resolvedPath = descriptor.path || ''; + const paramCount = (operation as OperationFunction).length || 0; + + const { regex, template, paramNames } = buildPathInfo( + resolvedPath, + paramCount, + ); + + // Extract body schema + const bodySchema = + descriptor.body && isSerializable(descriptor.body) + ? descriptor.body + : undefined; + + // Extract response schemas (only those that are Serializable) + const responseSchemas: Record = {}; + if (descriptor.responses) { + for (const [status, schema] of Object.entries(descriptor.responses)) { + if (isSerializable(schema)) { + responseSchemas[Number(status)] = schema; + } + } + } + + return { + method: descriptor.method, + path: resolvedPath, + pathTemplate: template, + pathParamNames: paramNames, + bodySchema, + responseSchemas, + requestHeaders: descriptor.headers as + | Record + | undefined, + _regex: regex, + } as RouteDefinition & { _regex: RegExp }; + }, + ); + + return { + routes, + + match( + method: string, + url: string, + basePath = '', + ): { route: RouteDefinition; params: Record } | null { + // Strip query string and base path + const pathname = url.split('?')[0]; + const relativePath = basePath + ? pathname.replace(new RegExp(`^${basePath}`), '') || '/' + : pathname; + + for (const route of routes) { + const routeWithRegex = route as RouteDefinition & { _regex: RegExp }; + + // Skip if method doesn't match + if (route.method !== method.toUpperCase()) continue; + + // Try to match the path + const match = relativePath.match(routeWithRegex._regex); + if (match) { + // Build params object from capture groups + const params: Record = {}; + for (let i = 0; i < route.pathParamNames.length; i++) { + params[route.pathParamNames[i]] = match[i + 1] || ''; + } + return { route, params }; + } + } + + return null; + }, + }; +} diff --git a/packages/speci/src/rest/server/index.ts b/packages/speci/src/rest/server/index.ts new file mode 100644 index 00000000..31bc1335 --- /dev/null +++ b/packages/speci/src/rest/server/index.ts @@ -0,0 +1,32 @@ +/** + * Speci REST - Server Module + * + * Server-side contract-to-route generation. + * This is the counterpart of the client module - it takes contracts + * and generates route definitions for HTTP servers. + * + * @example + * ```typescript + * import { createServer } from '@abapify/speci/rest/server'; + * + * const server = createServer(myContract); + * + * // Match incoming requests + * const match = server.match('GET', '/api/users/123'); + * if (match) { + * // match.route - RouteDefinition with schemas + * // match.params - { id: '123' } + * } + * ``` + */ + +export { createServer } from './create-server'; +export type { + RouteDefinition, + ServerRequest, + ServerResponse, + ServerHandler, + RouteContext, + ServerConfig, + ServerRoutes, +} from './types'; diff --git a/packages/speci/src/rest/server/types.ts b/packages/speci/src/rest/server/types.ts new file mode 100644 index 00000000..8252ece8 --- /dev/null +++ b/packages/speci/src/rest/server/types.ts @@ -0,0 +1,117 @@ +/** + * Speci REST - Server Types + * + * Framework-agnostic types for generating servers from REST contracts. + */ + +import type { + Serializable, + RestEndpointDescriptor, + ResponseMap, +} from '../types'; + +/** + * HTTP server request (abstraction over node:http IncomingMessage, Web Request, etc.) + */ +export interface ServerRequest { + method: string; + url: string; + headers: Record; + body?: string; + query: Record; +} + +/** + * HTTP server response (abstraction over node:http ServerResponse, Web Response, etc.) + */ +export interface ServerResponse { + status: number; + headers: Record; + body?: string; +} + +/** + * Extracted route definition from a contract endpoint. + * + * Contains all the information needed to register an HTTP route + * and perform content negotiation (JSON↔XML conversion). + */ +export interface RouteDefinition { + /** HTTP method (GET, POST, PUT, DELETE, etc.) */ + method: string; + + /** Resolved path pattern (e.g., '/sap/bc/adt/oo/classes/myclass') */ + path: string; + + /** Original path template before parameter resolution (e.g., '/sap/bc/adt/oo/classes/${name}') */ + pathTemplate: string; + + /** Path parameter names extracted from the template */ + pathParamNames: string[]; + + /** Request body schema (for JSON↔XML conversion of request bodies) */ + bodySchema?: Serializable; + + /** Response schemas keyed by HTTP status code */ + responseSchemas: Record; + + /** Expected request headers */ + requestHeaders?: Record; + + /** Response headers to include */ + responseHeaders?: Record; +} + +/** + * Server handler function for a route. + * + * Receives the parsed request and route context, returns a response. + */ +export type ServerHandler = ( + request: ServerRequest, + context: RouteContext, +) => Promise | ServerResponse; + +/** + * Context provided to route handlers + */ +export interface RouteContext { + /** The matched route definition */ + route: RouteDefinition; + + /** Extracted path parameters (e.g., { name: 'myclass' }) */ + params: Record; + + /** The original contract operation function */ + operation: (...args: any[]) => RestEndpointDescriptor; +} + +/** + * Configuration for createServer + */ +export interface ServerConfig { + /** + * Base URL prefix to strip from incoming requests before matching. + * For example, if your proxy is mounted at '/api/v1', set this to '/api/v1'. + * Defaults to '' (match against full path). + */ + basePath?: string; +} + +/** + * Result of createServer - a list of route definitions + */ +export interface ServerRoutes { + /** All extracted route definitions */ + routes: RouteDefinition[]; + + /** + * Match an incoming request to a route definition. + * Returns the matched route and extracted path parameters, or null. + */ + match( + method: string, + url: string, + basePath?: string, + ): { route: RouteDefinition; params: Record } | null; +} diff --git a/packages/speci/tsdown.config.ts b/packages/speci/tsdown.config.ts index 135c7a09..3a3920bf 100644 --- a/packages/speci/tsdown.config.ts +++ b/packages/speci/tsdown.config.ts @@ -3,6 +3,6 @@ import baseConfig from '../../tsdown.config.ts'; export default defineConfig({ ...baseConfig, - entry: ['src/index.ts', 'src/rest/index.ts'], + entry: ['src/index.ts', 'src/rest/index.ts', 'src/rest/server/index.ts'], tsconfig: 'tsconfig.json', }); From 98a8bc3795d69858394f62bfaeeb9b3023ecafe3 Mon Sep 17 00:00:00 2001 From: "Maple (gastown)" Date: Tue, 16 Jun 2026 23:26:47 +0000 Subject: [PATCH 02/11] refactor: reduce cyclomatic complexity in proxy server and createServer Address CodeScene complexity review feedback: - Extract handleMatchedRoute/handleForwardedRequest/sendResponse from handleRequest - Extract forwardHeaders/addAuthHeaders helpers - Extract convertRequestBody/convertResponseBody helpers - Extract tryExtractEndpoint from walkContract to reduce nesting - Flatten control flow with early returns --- packages/adt-proxy/src/proxy.ts | 487 +++++++++--------- .../speci/src/rest/server/create-server.ts | 51 +- 2 files changed, 267 insertions(+), 271 deletions(-) diff --git a/packages/adt-proxy/src/proxy.ts b/packages/adt-proxy/src/proxy.ts index ae155278..649c941c 100644 --- a/packages/adt-proxy/src/proxy.ts +++ b/packages/adt-proxy/src/proxy.ts @@ -22,10 +22,6 @@ * * const { port } = await proxy.start(); * console.log(`Proxy running on http://localhost:${port}`); - * - * // Now send JSON requests to the proxy: - * // curl -X GET http://localhost:${port}/sap/bc/adt/cts/transportrequests/DEVK900001 - * // The proxy converts the response XML → JSON automatically * ``` */ @@ -48,12 +44,104 @@ const DEFAULT_LOGGER: Logger = { error: console.error, }; -/** - * Create an ADT proxy server. - * - * @param config - Proxy configuration - * @returns Proxy server instance with start/stop methods - */ +const FORWARDABLE_REQUEST_HEADERS = [ + 'accept', + 'content-type', + 'x-csrf-token', + 'x-sap-security-session', + 'x-sap-adt-sessiontype', + 'cookie', + 'authorization', +]; + +const FORWARDABLE_RESPONSE_HEADERS = [ + 'content-type', + 'x-csrf-token', + 'x-sap-security-session', + 'set-cookie', + 'etag', + 'location', +]; + +function forwardHeaders( + source: Record, + target: Record, + keys: string[], +): void { + for (const h of keys) { + const value = source[h]; + if (value && !target[h]) { + target[h] = Array.isArray(value) ? value[0] : value; + } + } +} + +function addAuthHeaders( + headers: Record, + auth?: AdtProxyConfig['auth'], +): void { + if (!auth) return; + if (!headers.authorization) { + const creds = Buffer.from(`${auth.username}:${auth.password}`).toString( + 'base64', + ); + headers.authorization = `Basic ${creds}`; + } + if (auth.client && !headers['sap-client']) { + headers['sap-client'] = auth.client; + } +} + +function convertRequestBody( + body: string, + contentType: string | undefined, + bodySchema?: RouteDefinition['bodySchema'], +): { body: string; converted: boolean } { + if (!body || !bodySchema || !contentType) { + return { body, converted: false }; + } + if (!isJsonContentType(contentType)) { + return { body, converted: false }; + } + return { body: jsonToXml(body, bodySchema), converted: true }; +} + +function convertResponseBody( + body: string, + contentType: string, + responseSchemas: RouteDefinition['responseSchemas'], +): { body: string; converted: boolean } { + if (!body || !isXmlContentType(contentType) || !responseSchemas[200]) { + return { body, converted: false }; + } + return { body: xmlToJson(body, responseSchemas[200]), converted: true }; +} + +async function forwardRequest( + method: string, + url: string, + headers: Record, + body?: string, +): Promise<{ status: number; headers: Record; body: string }> { + const response = await fetch(url, { + method, + headers, + body: body || undefined, + }); + + const responseBody = await response.text(); + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + return { + status: response.status, + headers: responseHeaders, + body: responseBody, + }; +} + export function createAdtProxy(config: AdtProxyConfig) { const { targetUrl, @@ -65,23 +153,25 @@ export function createAdtProxy(config: AdtProxyConfig) { logger = DEFAULT_LOGGER, } = config; - // Extract routes from ADT contracts const contractRoutes = createServer(adtContract as AdtContract); let server: Server | undefined; - /** - * Match an incoming request to a contract route. - */ - function matchRoute( - method: string, - url: string, - ): { route: RouteDefinition; params: Record } | null { - return contractRoutes.match(method, url, basePath); + function buildDownstreamHeaders( + incomingHeaders: Record, + contractHeaders?: Record, + ): Record { + const headers: Record = { + ...defaultHeaders, + ...contractHeaders, + }; + forwardHeaders(incomingHeaders, headers, FORWARDABLE_REQUEST_HEADERS); + addAuthHeaders(headers, auth); + if (convertContent && !headers.accept) { + headers.accept = 'application/json'; + } + return headers; } - /** - * Build the downstream URL from the incoming request URL. - */ function buildDownstreamUrl(url: string): string { const relativePath = basePath ? url.replace(new RegExp(`^${basePath}`), '') || '/' @@ -89,102 +179,136 @@ export function createAdtProxy(config: AdtProxyConfig) { return `${targetUrl}${relativePath}`; } - /** - * Build Basic Auth header from config. - */ - function getAuthHeader(): string | undefined { - if (!auth) return undefined; - const credentials = Buffer.from( - `${auth.username}:${auth.password}`, - ).toString('base64'); - return `Basic ${credentials}`; + function getContentType( + headers: Record, + ): string | undefined { + const ct = headers['content-type']; + return Array.isArray(ct) ? ct[0] : ct; } - /** - * Build downstream request headers. - */ - function buildDownstreamHeaders( + async function handleMatchedRoute( + method: string, + url: string, + requestBody: string, incomingHeaders: Record, - contractHeaders?: Record, - isXmlBody = false, - ): Record { - const headers: Record = { - ...defaultHeaders, - ...contractHeaders, - }; - - // Forward relevant headers from the incoming request - const forwardableHeaders = [ - 'accept', - 'content-type', - 'x-csrf-token', - 'x-sap-security-session', - 'x-sap-adt-sessiontype', - 'cookie', - 'authorization', - ]; - - for (const h of forwardableHeaders) { - const value = incomingHeaders[h]; - if (value && !headers[h]) { - headers[h] = Array.isArray(value) ? value[0] : value; - } - } - - // Add auth if configured and not already present - if (auth && !headers.authorization) { - const authHeader = getAuthHeader(); - if (authHeader) headers.authorization = authHeader; - } - - // Add SAP client if configured - if (auth?.client && !headers['sap-client']) { - headers['sap-client'] = auth.client; + route: RouteDefinition, + ): Promise { + logger.info(`Matched route: ${route.method} ${route.pathTemplate}`); + + const reqContentType = getContentType(incomingHeaders); + const { body: downstreamBody, converted: reqConverted } = + convertRequestBody(requestBody, reqContentType, route.bodySchema); + + const downstreamHeaders = buildDownstreamHeaders( + incomingHeaders, + route.requestHeaders, + ); + if (reqConverted) { + downstreamHeaders['content-type'] = 'application/xml'; } - // Set Accept to JSON for the proxy (we want JSON responses) - if (convertContent && !headers.accept) { - headers.accept = 'application/json'; - } + const response = await forwardRequest( + method, + buildDownstreamUrl(url), + downstreamHeaders, + downstreamBody, + ); + + const respContentType = response.headers['content-type'] || ''; + const { body: responseBody, converted: respConverted } = + convertResponseBody( + response.body, + respContentType, + route.responseSchemas, + ); - return headers; + return { + status: response.status, + headers: response.headers, + body: responseBody, + converted: respConverted, + }; } - /** - * Forward a request to the downstream SAP system. - */ - async function forwardRequest( + async function handleForwardedRequest( method: string, url: string, - headers: Record, - body?: string, - ): Promise<{ - status: number; - headers: Record; - body: string; - }> { - const response = await fetch(url, { - method, - headers, - body: body || undefined, - }); + requestBody: string, + incomingHeaders: Record, + ): Promise { + logger.debug(`No route match for ${method} ${url} - forwarding as-is`); - const responseBody = await response.text(); - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); + const downstreamHeaders = buildDownstreamHeaders(incomingHeaders); + const response = await forwardRequest( + method, + buildDownstreamUrl(url), + downstreamHeaders, + requestBody, + ); return { status: response.status, - headers: responseHeaders, - body: responseBody, + headers: response.headers, + body: response.body, + converted: false, }; } - /** - * Handle a single incoming HTTP request. - */ + function sendNotFound( + res: { + writeHead: (status: number, headers: Record) => void; + end: (body?: string) => void; + }, + method: string, + url: string, + ): void { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'Not Found', + message: `No contract route matched for ${method} ${url}`, + availableRoutes: contractRoutes.routes.map((r) => ({ + method: r.method, + path: r.pathTemplate, + })), + }), + ); + } + + function sendResponse( + res: { + writeHead: (status: number, headers: Record) => void; + end: (body?: string) => void; + }, + result: ProxyResult, + ): void { + const responseHeaders: Record = { 'x-proxy': 'adt-proxy' }; + forwardHeaders( + result.headers, + responseHeaders, + FORWARDABLE_RESPONSE_HEADERS, + ); + + if (result.converted) { + responseHeaders['content-type'] = 'application/json'; + } + + res.writeHead(result.status, responseHeaders); + res.end(result.body); + } + + async function readBody(req: { + on: (event: string, cb: (chunk: any) => void) => void; + }): Promise { + return new Promise((resolve) => { + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on('end', () => resolve(body)); + }); + } + async function handleRequest( req: { method?: string; @@ -199,168 +323,43 @@ export function createAdtProxy(config: AdtProxyConfig) { ): Promise { const method = req.method || 'GET'; const url = req.url || '/'; - logger.info(`${method} ${url}`); - // Read request body - let requestBody = ''; - await new Promise((resolve) => { - req.on('data', (chunk: Buffer) => { - requestBody += chunk.toString(); - }); - req.on('end', () => resolve()); - }); - - // Match to a contract route - const match = matchRoute(method, url); - - let result: ProxyResult; + const requestBody = await readBody(req); + const match = contractRoutes.match(method, url, basePath); if (match && convertContent) { - // Route matched - use schema-aware conversion - const { route, params } = match; - logger.info(`Matched route: ${route.method} ${route.pathTemplate}`, { - params, - }); - - // Convert JSON body → XML if needed - let downstreamBody = requestBody; - if (requestBody && route.bodySchema) { - const contentType = Array.isArray(req.headers['content-type']) - ? req.headers['content-type'][0] - : req.headers['content-type']; - - if (contentType && isJsonContentType(contentType)) { - downstreamBody = jsonToXml(requestBody, route.bodySchema); - logger.debug('Converted request body: JSON → XML'); - } - } - - // Build downstream headers - const downstreamHeaders = buildDownstreamHeaders( - req.headers, - route.requestHeaders, - downstreamBody !== requestBody, - ); - - // Set Content-Type for XML body - if (downstreamBody !== requestBody) { - downstreamHeaders['content-type'] = 'application/xml'; - } - - // Forward to downstream SAP - const downstreamUrl = buildDownstreamUrl(url); - const response = await forwardRequest( + const result = await handleMatchedRoute( method, - downstreamUrl, - downstreamHeaders, - downstreamBody, + url, + requestBody, + req.headers, + match.route, ); + sendResponse(res, result); + return; + } - // Convert XML response → JSON if needed - let responseBody = response.body; - let converted = false; - if (response.body && route.responseSchemas[200]) { - const respContentType = response.headers['content-type'] || ''; - if (isXmlContentType(respContentType)) { - responseBody = xmlToJson(response.body, route.responseSchemas[200]); - converted = true; - logger.debug('Converted response body: XML → JSON'); - } - } - - result = { - status: response.status, - headers: response.headers, - body: responseBody, - converted, - }; - } else if (forwardUnknown) { - // No route match - forward as-is - if (match === null) { - logger.debug(`No route match for ${method} ${url} - forwarding as-is`); - } - - const downstreamHeaders = buildDownstreamHeaders(req.headers); - const downstreamUrl = buildDownstreamUrl(url); - - const response = await forwardRequest( + if (forwardUnknown) { + const result = await handleForwardedRequest( method, - downstreamUrl, - downstreamHeaders, + url, requestBody, + req.headers, ); - - result = { - status: response.status, - headers: response.headers, - body: response.body, - converted: false, - }; - } else { - // No route match and forwarding disabled - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Not Found', - message: `No contract route matched for ${method} ${url}`, - availableRoutes: contractRoutes.routes.map((r) => ({ - method: r.method, - path: r.pathTemplate, - })), - }), - ); + sendResponse(res, result); return; } - // Send response - const responseHeaders: Record = { - 'x-proxy': 'adt-proxy', - }; - - // Forward relevant response headers - const forwardableResponseHeaders = [ - 'content-type', - 'x-csrf-token', - 'x-sap-security-session', - 'set-cookie', - 'etag', - 'location', - ]; - - for (const h of forwardableResponseHeaders) { - if (result.headers[h]) { - responseHeaders[h] = result.headers[h]; - } - } - - // Set content type to JSON if we converted - if (result.converted) { - responseHeaders['content-type'] = 'application/json'; - } - - res.writeHead(result.status, responseHeaders); - res.end(result.body); + sendNotFound(res, method, url); } return { - /** - * Get the extracted route definitions (for inspection/testing). - */ get routes() { return contractRoutes.routes; }, - - /** - * Match a request against the contract routes. - */ match: contractRoutes.match, - /** - * Start the proxy server. - * - * @returns The port the server is listening on - */ async start(): Promise<{ port: number }> { return new Promise((resolve, reject) => { server = httpCreateServer((req, res) => { @@ -393,9 +392,6 @@ export function createAdtProxy(config: AdtProxyConfig) { }); }, - /** - * Stop the proxy server. - */ async stop(): Promise { return new Promise((resolve, reject) => { if (!server) return resolve(); @@ -405,7 +401,6 @@ export function createAdtProxy(config: AdtProxyConfig) { }; } -// Re-export types and utilities export { createServer } from '@abapify/speci/rest'; export type { AdtProxyConfig, ProxyResult, Logger } from './types'; export { diff --git a/packages/speci/src/rest/server/create-server.ts b/packages/speci/src/rest/server/create-server.ts index 480df52b..471b4d3a 100644 --- a/packages/speci/src/rest/server/create-server.ts +++ b/packages/speci/src/rest/server/create-server.ts @@ -64,42 +64,43 @@ function isSerializable(schema: unknown): schema is Serializable { * Calls each operation function with a sentinel value to get the resolved * path, then replaces the sentinel with regex capture groups for matching. */ +function tryExtractEndpoint( + obj: OperationFunction, +): { operation: OperationFunction; descriptor: RestEndpointDescriptor } | null { + try { + const paramCount = obj.length || 1; + const sentinelArgs = Array.from({ length: paramCount }, () => PARAM_MARKER); + const descriptor = obj(...sentinelArgs); + if (isEndpointDescriptor(descriptor)) { + return { operation: obj, descriptor }; + } + } catch { + // Some functions may need different args - skip + } + return null; +} + function walkContract( obj: any, parentPath: string[], ): Array<{ operation: OperationFunction; descriptor: RestEndpointDescriptor }> { - const endpoints: Array<{ - operation: OperationFunction; - descriptor: RestEndpointDescriptor; - }> = []; - if (typeof obj === 'function') { - try { - // Call with sentinel values for all parameters to get the resolved path. - // The function's .length gives us the declared parameter count. - const paramCount = (obj as OperationFunction).length || 1; - const sentinelArgs = Array.from( - { length: paramCount }, - () => PARAM_MARKER, - ); - const descriptor = (obj as OperationFunction)(...sentinelArgs); + const result = tryExtractEndpoint(obj as OperationFunction); + return result ? [result] : []; + } - if (isEndpointDescriptor(descriptor)) { - endpoints.push({ - operation: obj as OperationFunction, - descriptor, - }); - } - } catch { - // Some functions may need different args - skip - } - } else if (typeof obj === 'object' && obj !== null) { + if (typeof obj === 'object' && obj !== null) { + const endpoints: Array<{ + operation: OperationFunction; + descriptor: RestEndpointDescriptor; + }> = []; for (const [key, value] of Object.entries(obj)) { endpoints.push(...walkContract(value, [...parentPath, key])); } + return endpoints; } - return endpoints; + return []; } /** From cad2b1a66a6538a2ad62ed9b54b543312dbf6d66 Mon Sep 17 00:00:00 2001 From: "Maple (gastown)" Date: Tue, 16 Jun 2026 23:30:33 +0000 Subject: [PATCH 03/11] fix: address CodeRabbit review feedback - Fix ReDoS vulnerability: escape special regex characters in basePath - Fix cookie auth forwarding: pass cookie/bearer headers via defaultHeaders instead of dropping them when cookie-based auth is detected --- packages/adt-cli/src/lib/commands/proxy.ts | 16 +++++++++++----- packages/adt-proxy/src/proxy.ts | 5 ++++- packages/speci/src/rest/server/create-server.ts | 5 ++++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/adt-cli/src/lib/commands/proxy.ts b/packages/adt-cli/src/lib/commands/proxy.ts index 9aa7bf18..d2125bfe 100644 --- a/packages/adt-cli/src/lib/commands/proxy.ts +++ b/packages/adt-cli/src/lib/commands/proxy.ts @@ -39,6 +39,7 @@ export const proxyCommand = new Command('proxy') let auth: | { username: string; password: string; client?: string } | undefined; + let defaultHeaders: Record = {}; if (!targetUrl) { // Use current auth session @@ -64,12 +65,16 @@ export const proxyCommand = new Command('proxy') client: session.client, }; } else if (session.auth.method === 'cookie') { - // Cookie auth - pass cookies as header const creds = session.auth.credentials as { cookies: string }; - auth = undefined; // Will use cookie header instead - console.warn( - '⚠️ Cookie-based auth detected. Cookie will be forwarded to downstream.', - ); + const rawCookies = decodeURIComponent(creds.cookies); + const AUTH_PREFIX = 'Authorization: '; + if (rawCookies.startsWith(AUTH_PREFIX)) { + defaultHeaders.authorization = rawCookies.substring( + AUTH_PREFIX.length, + ); + } else { + defaultHeaders.cookie = rawCookies; + } } } @@ -93,6 +98,7 @@ export const proxyCommand = new Command('proxy') const proxy = createAdtProxy({ targetUrl, auth, + defaultHeaders, port: options.port, host: options.host, basePath: options.basePath, diff --git a/packages/adt-proxy/src/proxy.ts b/packages/adt-proxy/src/proxy.ts index 649c941c..55b3579c 100644 --- a/packages/adt-proxy/src/proxy.ts +++ b/packages/adt-proxy/src/proxy.ts @@ -174,7 +174,10 @@ export function createAdtProxy(config: AdtProxyConfig) { function buildDownstreamUrl(url: string): string { const relativePath = basePath - ? url.replace(new RegExp(`^${basePath}`), '') || '/' + ? url.replace( + new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + '', + ) || '/' : url; return `${targetUrl}${relativePath}`; } diff --git a/packages/speci/src/rest/server/create-server.ts b/packages/speci/src/rest/server/create-server.ts index 471b4d3a..060ecae4 100644 --- a/packages/speci/src/rest/server/create-server.ts +++ b/packages/speci/src/rest/server/create-server.ts @@ -227,7 +227,10 @@ export function createServer>( // Strip query string and base path const pathname = url.split('?')[0]; const relativePath = basePath - ? pathname.replace(new RegExp(`^${basePath}`), '') || '/' + ? pathname.replace( + new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + '', + ) || '/' : pathname; for (const route of routes) { From dcba89f3823fa7ffcc81efbef96bfdc3f4a6d44c Mon Sep 17 00:00:00 2001 From: "Maple (gastown)" Date: Tue, 16 Jun 2026 23:42:39 +0000 Subject: [PATCH 04/11] fix: address all review feedback - round 2 - Fix case-insensitive content-type detection - Fix response schema lookup to use response.status - Fix Set-Cookie handling with getSetCookie() - Don't forward user Authorization when proxy auth is configured - Fix port validation with proper NaN check - Fix session.auth undefined check - Fix basePath matching with startsWith/slice - Remove unused ResponseMap import - Support string[] for multi-value headers - Fix duplicate sendResponse block --- packages/adt-cli/src/lib/commands/proxy.ts | 10 +++- packages/adt-proxy/src/converter.ts | 30 ++++-------- packages/adt-proxy/src/proxy.ts | 49 ++++++++++++++----- packages/adt-proxy/src/types.ts | 2 +- .../speci/src/rest/server/create-server.ts | 10 ++-- packages/speci/src/rest/server/types.ts | 6 +-- 6 files changed, 61 insertions(+), 46 deletions(-) diff --git a/packages/adt-cli/src/lib/commands/proxy.ts b/packages/adt-cli/src/lib/commands/proxy.ts index d2125bfe..db3635ba 100644 --- a/packages/adt-cli/src/lib/commands/proxy.ts +++ b/packages/adt-cli/src/lib/commands/proxy.ts @@ -7,7 +7,13 @@ export const proxyCommand = new Command('proxy') .option( '-p, --port ', 'Port to listen on (default: random available port)', - parseInt, + (val: string) => { + const parsed = parseInt(val, 10); + if (Number.isNaN(parsed) || parsed < 0 || parsed > 65535) { + throw new Error(`Invalid port: ${val}`); + } + return parsed; + }, ) .option( '-H, --host ', @@ -44,7 +50,7 @@ export const proxyCommand = new Command('proxy') if (!targetUrl) { // Use current auth session const session = loadAuthSession(options.sid); - if (!session) { + if (!session || !session.auth) { console.error('❌ Not authenticated and no --target specified'); console.error( '💡 Run "npx adt auth login" or provide --target ', diff --git a/packages/adt-proxy/src/converter.ts b/packages/adt-proxy/src/converter.ts index 799c8012..3d1ecaaf 100644 --- a/packages/adt-proxy/src/converter.ts +++ b/packages/adt-proxy/src/converter.ts @@ -9,10 +9,6 @@ import type { Serializable } from '@abapify/speci/rest'; /** * Convert a JSON string to XML using a schema's build method. - * - * @param json - The JSON string to convert - * @param schema - The schema with a build() method - * @returns The XML string, or the original JSON if conversion fails */ export function jsonToXml(json: string, schema: Serializable): string { try { @@ -20,27 +16,20 @@ export function jsonToXml(json: string, schema: Serializable): string { if (typeof schema.build === 'function') { return schema.build(data); } - // No build method - return as-is return json; } catch { - // If parsing fails, return the original string return json; } } /** * Convert an XML string to JSON using a schema's parse method. - * - * @param xml - The XML string to convert - * @param schema - The schema with a parse() method - * @returns The JSON string, or the original XML if conversion fails */ export function xmlToJson(xml: string, schema: Serializable): string { try { const data = schema.parse(xml); return JSON.stringify(data); } catch { - // If parsing fails, return the original string return xml; } } @@ -52,15 +41,14 @@ export function detectContentType( content: string, contentType?: string, ): 'json' | 'xml' | 'text' | 'binary' { - // Check explicit content-type header first if (contentType) { - if (contentType.includes('json')) return 'json'; - if (contentType.includes('xml')) return 'xml'; - if (contentType.includes('text')) return 'text'; + const ct = contentType.toLowerCase(); + if (ct.includes('json')) return 'json'; + if (ct.includes('xml')) return 'xml'; + if (ct.includes('text')) return 'text'; return 'binary'; } - // Heuristic detection const trimmed = content.trimStart(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 'json'; if (trimmed.startsWith(', - target: Record, + target: Record, keys: string[], ): void { for (const h of keys) { const value = source[h]; if (value && !target[h]) { - target[h] = Array.isArray(value) ? value[0] : value; + target[h] = value; } } } @@ -109,12 +108,13 @@ function convertRequestBody( function convertResponseBody( body: string, contentType: string, + status: number, responseSchemas: RouteDefinition['responseSchemas'], ): { body: string; converted: boolean } { - if (!body || !isXmlContentType(contentType) || !responseSchemas[200]) { + if (!body || !isXmlContentType(contentType) || !responseSchemas[status]) { return { body, converted: false }; } - return { body: xmlToJson(body, responseSchemas[200]), converted: true }; + return { body: xmlToJson(body, responseSchemas[status]), converted: true }; } async function forwardRequest( @@ -122,7 +122,11 @@ async function forwardRequest( url: string, headers: Record, body?: string, -): Promise<{ status: number; headers: Record; body: string }> { +): Promise<{ + status: number; + headers: Record; + body: string; +}> { const response = await fetch(url, { method, headers, @@ -130,11 +134,18 @@ async function forwardRequest( }); const responseBody = await response.text(); - const responseHeaders: Record = {}; + const responseHeaders: Record = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); + if (typeof response.headers.getSetCookie === 'function') { + const setCookies = response.headers.getSetCookie(); + if (setCookies.length > 0) { + responseHeaders['set-cookie'] = setCookies; + } + } + return { status: response.status, headers: responseHeaders, @@ -217,11 +228,14 @@ export function createAdtProxy(config: AdtProxyConfig) { downstreamBody, ); - const respContentType = response.headers['content-type'] || ''; + const respContentType = Array.isArray(response.headers['content-type']) + ? response.headers['content-type'][0] + : response.headers['content-type'] || ''; const { body: responseBody, converted: respConverted } = convertResponseBody( response.body, respContentType, + response.status, route.responseSchemas, ); @@ -259,7 +273,10 @@ export function createAdtProxy(config: AdtProxyConfig) { function sendNotFound( res: { - writeHead: (status: number, headers: Record) => void; + writeHead: ( + status: number, + headers: Record, + ) => void; end: (body?: string) => void; }, method: string, @@ -280,12 +297,17 @@ export function createAdtProxy(config: AdtProxyConfig) { function sendResponse( res: { - writeHead: (status: number, headers: Record) => void; + writeHead: ( + status: number, + headers: Record, + ) => void; end: (body?: string) => void; }, result: ProxyResult, ): void { - const responseHeaders: Record = { 'x-proxy': 'adt-proxy' }; + const responseHeaders: Record = { + 'x-proxy': 'adt-proxy', + }; forwardHeaders( result.headers, responseHeaders, @@ -320,7 +342,10 @@ export function createAdtProxy(config: AdtProxyConfig) { on: (event: string, cb: (chunk: any) => void) => void; }, res: { - writeHead: (status: number, headers: Record) => void; + writeHead: ( + status: number, + headers: Record, + ) => void; end: (body?: string) => void; }, ): Promise { diff --git a/packages/adt-proxy/src/types.ts b/packages/adt-proxy/src/types.ts index 5f3acad8..4e3fda19 100644 --- a/packages/adt-proxy/src/types.ts +++ b/packages/adt-proxy/src/types.ts @@ -79,7 +79,7 @@ export interface ProxyResult { status: number; /** Response headers from downstream */ - headers: Record; + headers: Record; /** Response body (JSON string if converted, raw string otherwise) */ body: string; diff --git a/packages/speci/src/rest/server/create-server.ts b/packages/speci/src/rest/server/create-server.ts index 060ecae4..901699c0 100644 --- a/packages/speci/src/rest/server/create-server.ts +++ b/packages/speci/src/rest/server/create-server.ts @@ -226,12 +226,10 @@ export function createServer>( ): { route: RouteDefinition; params: Record } | null { // Strip query string and base path const pathname = url.split('?')[0]; - const relativePath = basePath - ? pathname.replace( - new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), - '', - ) || '/' - : pathname; + const relativePath = + basePath && pathname.startsWith(basePath) + ? pathname.slice(basePath.length) || '/' + : pathname; for (const route of routes) { const routeWithRegex = route as RouteDefinition & { _regex: RegExp }; diff --git a/packages/speci/src/rest/server/types.ts b/packages/speci/src/rest/server/types.ts index 8252ece8..86c2c4cb 100644 --- a/packages/speci/src/rest/server/types.ts +++ b/packages/speci/src/rest/server/types.ts @@ -4,11 +4,7 @@ * Framework-agnostic types for generating servers from REST contracts. */ -import type { - Serializable, - RestEndpointDescriptor, - ResponseMap, -} from '../types'; +import type { Serializable, RestEndpointDescriptor } from '../types'; /** * HTTP server request (abstraction over node:http IncomingMessage, Web Request, etc.) From 4bf8846c7e43681102e5509af60f9db6b865c623 Mon Sep 17 00:00:00 2001 From: "Maple (gastown)" Date: Tue, 16 Jun 2026 23:50:22 +0000 Subject: [PATCH 05/11] fix: address CI feedback - error handling, fetch timeout, server crash risk - Add error event handler to readBody promise (SonarCloud reliability fix) - Add AbortController timeout (30s) to downstream fetch calls - Register server error handler before listen() to prevent startup crash - Fix convertResponseBody to check actual conversion success - Simplify PARAM_MARKER replacement using split/join --- bun.lock | 212 ++++++++++-------- packages/adt-proxy/src/proxy.ts | 66 +++--- .../speci/src/rest/server/create-server.ts | 5 +- 3 files changed, 155 insertions(+), 128 deletions(-) diff --git a/bun.lock b/bun.lock index aa625b0d..c9cd49da 100644 --- a/bun.lock +++ b/bun.lock @@ -82,84 +82,85 @@ }, "packages/abap-ast": { "name": "@abapify/abap-ast", - "version": "0.3.6", + "version": "0.4.1", }, "packages/acds": { "name": "@abapify/acds", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { "chevrotain": "^11.0.0", }, }, "packages/aclass": { "name": "@abapify/aclass", - "version": "0.1.0", + "version": "0.4.1", "dependencies": { "chevrotain": "^11.0.0", }, }, "packages/adk": { "name": "@abapify/adk", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adt-client": "0.3.6", - "@abapify/adt-locks": "0.3.6", - "@abapify/adt-schemas": "0.3.6", + "@abapify/adt-client": "0.4.1", + "@abapify/adt-locks": "0.4.1", + "@abapify/adt-schemas": "0.4.1", }, }, "packages/adt-atc": { "name": "@abapify/adt-atc", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adt-plugin": "0.3.6", + "@abapify/adt-plugin": "0.4.1", "chalk": "^5.3.0", }, }, "packages/adt-aunit": { "name": "@abapify/adt-aunit", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adt-contracts": "0.3.6", - "@abapify/adt-plugin": "0.3.6", - "@abapify/adt-plugin-abapgit": "0.3.6", - "@abapify/adt-schemas": "0.3.6", + "@abapify/adt-contracts": "0.4.1", + "@abapify/adt-plugin": "0.4.1", + "@abapify/adt-plugin-abapgit": "0.4.1", + "@abapify/adt-schemas": "0.4.1", }, }, "packages/adt-auth": { "name": "@abapify/adt-auth", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/logger": "0.3.6", + "@abapify/logger": "0.4.1", "proxy-agent": "^6.4.0", }, }, "packages/adt-cli": { "name": "@abapify/adt-cli", - "version": "0.3.6", + "version": "0.4.1", "bin": { "adt": "./dist/bin/adt.mjs", }, "dependencies": { - "@abapify/adk": "0.3.6", - "@abapify/adt-atc": "0.3.6", - "@abapify/adt-aunit": "0.3.6", - "@abapify/adt-auth": "0.3.6", - "@abapify/adt-client": "0.3.6", - "@abapify/adt-codegen": "0.3.6", - "@abapify/adt-config": "0.3.6", - "@abapify/adt-contracts": "0.3.6", - "@abapify/adt-diff": "0.3.6", - "@abapify/adt-export": "0.3.6", - "@abapify/adt-lint": "0.3.6", - "@abapify/adt-locks": "0.3.6", - "@abapify/adt-plugin": "0.3.6", - "@abapify/adt-plugin-abapgit": "0.3.6", - "@abapify/adt-plugin-gcts": "0.3.6", - "@abapify/adt-plugin-gcts-cli": "0.3.6", - "@abapify/adt-rfc": "0.3.6", - "@abapify/adt-schemas": "0.3.6", - "@abapify/adt-tui": "0.3.6", - "@abapify/logger": "0.3.6", + "@abapify/adk": "0.4.1", + "@abapify/adt-atc": "0.4.1", + "@abapify/adt-aunit": "0.4.1", + "@abapify/adt-auth": "0.4.1", + "@abapify/adt-client": "0.4.1", + "@abapify/adt-codegen": "0.4.1", + "@abapify/adt-config": "0.4.1", + "@abapify/adt-contracts": "0.4.1", + "@abapify/adt-diff": "0.4.1", + "@abapify/adt-export": "0.4.1", + "@abapify/adt-lint": "0.4.1", + "@abapify/adt-locks": "0.4.1", + "@abapify/adt-plugin": "0.4.1", + "@abapify/adt-plugin-abapgit": "0.4.1", + "@abapify/adt-plugin-gcts": "0.4.1", + "@abapify/adt-plugin-gcts-cli": "0.4.1", + "@abapify/adt-proxy": "0.4.1", + "@abapify/adt-rfc": "0.4.1", + "@abapify/adt-schemas": "0.4.1", + "@abapify/adt-tui": "0.4.1", + "@abapify/logger": "0.4.1", "@inquirer/prompts": "^7.9.0", "@xmldom/xmldom": "^0.9.9", "chalk": "^5.6.2", @@ -178,22 +179,22 @@ }, "packages/adt-client": { "name": "@abapify/adt-client", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adt-contracts": "0.3.6", - "@abapify/adt-schemas": "0.3.6", - "@abapify/logger": "0.3.6", + "@abapify/adt-contracts": "0.4.1", + "@abapify/adt-schemas": "0.4.1", + "@abapify/logger": "0.4.1", }, }, "packages/adt-codegen": { "name": "@abapify/adt-codegen", - "version": "0.3.6", + "version": "0.4.1", "bin": { "adt-codegen": "./dist/cli.mjs", }, "dependencies": { - "@abapify/adt-config": "0.3.6", - "@abapify/adt-plugin": "0.3.6", + "@abapify/adt-config": "0.4.1", + "@abapify/adt-plugin": "0.4.1", "chalk": "^5.3.0", "commander": "^11.1.0", "fast-xml-parser": "^5.5.0", @@ -201,43 +202,43 @@ }, "packages/adt-config": { "name": "@abapify/adt-config", - "version": "0.3.6", + "version": "0.4.1", }, "packages/adt-contracts": { "name": "@abapify/adt-contracts", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adt-schemas": "0.3.6", - "@abapify/speci": "0.3.6", + "@abapify/adt-schemas": "0.4.1", + "@abapify/speci": "0.4.1", }, "devDependencies": { - "@abapify/adt-fixtures": "0.3.6", + "@abapify/adt-fixtures": "0.4.1", }, }, "packages/adt-diff": { "name": "@abapify/adt-diff", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adk": "0.3.6", - "@abapify/adt-contracts": "0.3.6", - "@abapify/adt-plugin": "0.3.6", - "@abapify/adt-plugin-abapgit": "0.3.6", + "@abapify/adk": "0.4.1", + "@abapify/adt-contracts": "0.4.1", + "@abapify/adt-plugin": "0.4.1", + "@abapify/adt-plugin-abapgit": "0.4.1", "chalk": "^5.6.2", "diff": "^8.0.3", "fast-xml-parser": "^5.5.5", }, "devDependencies": { - "@abapify/adt-fixtures": "0.3.6", + "@abapify/adt-fixtures": "0.4.1", }, }, "packages/adt-export": { "name": "@abapify/adt-export", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adk": "0.3.6", - "@abapify/adt-locks": "0.3.6", - "@abapify/adt-plugin": "0.3.6", - "@abapify/adt-plugin-abapgit": "0.3.6", + "@abapify/adk": "0.4.1", + "@abapify/adt-locks": "0.4.1", + "@abapify/adt-plugin": "0.4.1", + "@abapify/adt-plugin-abapgit": "0.4.1", "chalk": "^5.6.2", "diff": "^8.0.3", "fast-xml-parser": "^5.5.5", @@ -245,25 +246,25 @@ }, "packages/adt-fixtures": { "name": "@abapify/adt-fixtures", - "version": "0.3.6", + "version": "0.4.1", }, "packages/adt-lint": { "name": "@abapify/adt-lint", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { "@abaplint/core": "^2.119.15", }, }, "packages/adt-locks": { "name": "@abapify/adt-locks", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adt-client": "0.3.6", + "@abapify/adt-client": "0.4.1", }, }, "packages/adt-mcp": { "name": "@abapify/adt-mcp", - "version": "0.3.6", + "version": "0.4.1", "bin": { "adt-mcp": "./dist/bin/adt-mcp.mjs", "adt-mcp-http": "./dist/bin/adt-mcp-http.mjs", @@ -289,7 +290,7 @@ }, "packages/adt-pilot": { "name": "@abapify/adt-pilot", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { "@mastra/core": "^1.28.0", "@modelcontextprotocol/sdk": "^1.27.0", @@ -304,7 +305,7 @@ }, "packages/adt-playwright": { "name": "@abapify/adt-playwright", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { "@abapify/adt-auth": "workspace:*", "@abapify/adt-config": "workspace:*", @@ -317,43 +318,52 @@ }, "packages/adt-plugin": { "name": "@abapify/adt-plugin", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adk": "0.3.6", + "@abapify/adk": "0.4.1", }, }, "packages/adt-plugin-abapgit": { "name": "@abapify/adt-plugin-abapgit", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/acds": "0.3.6", - "@abapify/adk": "0.3.6", - "@abapify/adt-atc": "0.3.6", - "@abapify/adt-plugin": "0.3.6", - "@abapify/adt-schemas": "0.3.6", - "@abapify/ts-xsd": "0.3.6", + "@abapify/acds": "0.4.1", + "@abapify/adk": "0.4.1", + "@abapify/adt-atc": "0.4.1", + "@abapify/adt-plugin": "0.4.1", + "@abapify/adt-schemas": "0.4.1", + "@abapify/ts-xsd": "0.4.1", }, }, "packages/adt-plugin-gcts": { "name": "@abapify/adt-plugin-gcts", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adk": "0.3.6", - "@abapify/adt-plugin": "0.3.6", + "@abapify/adk": "0.4.1", + "@abapify/adt-plugin": "0.4.1", }, }, "packages/adt-plugin-gcts-cli": { "name": "@abapify/adt-plugin-gcts-cli", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adt-contracts": "0.3.6", - "@abapify/adt-plugin": "0.3.6", + "@abapify/adt-contracts": "0.4.1", + "@abapify/adt-plugin": "0.4.1", "zod": "^3.23.8", }, }, + "packages/adt-proxy": { + "name": "@abapify/adt-proxy", + "version": "0.4.1", + "dependencies": { + "@abapify/adt-contracts": "0.4.1", + "@abapify/speci": "0.4.1", + "fast-xml-parser": "^5.5.3", + }, + }, "packages/adt-puppeteer": { "name": "@abapify/adt-puppeteer", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { "@abapify/adt-auth": "workspace:*", "@abapify/adt-config": "workspace:*", @@ -366,24 +376,24 @@ }, "packages/adt-rfc": { "name": "@abapify/adt-rfc", - "version": "0.3.6", + "version": "0.4.1", }, "packages/adt-schemas": { "name": "@abapify/adt-schemas", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/ts-xsd": "0.3.6", + "@abapify/ts-xsd": "0.4.1", "zod": "^3.24.0 || ^4.0.0", }, "devDependencies": { - "@abapify/adt-fixtures": "0.3.6", + "@abapify/adt-fixtures": "0.4.1", }, }, "packages/adt-tui": { "name": "@abapify/adt-tui", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { - "@abapify/adt-contracts": "0.3.6", + "@abapify/adt-contracts": "0.4.1", "fast-xml-parser": "^5.3.1", "ink": "5.1.0", "ink-select-input": "^6.0.0", @@ -395,19 +405,19 @@ "@types/react": "18.3.0", }, "peerDependencies": { - "@abapify/adt-client": "0.3.6", + "@abapify/adt-client": "0.4.1", }, }, "packages/asjson-parser": { "name": "@abapify/asjson-parser", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { "jsonc-eslint-parser": "^2.1.0", }, }, "packages/browser-auth": { "name": "@abapify/browser-auth", - "version": "0.3.6", + "version": "0.4.1", "dependencies": { "@abapify/adt-config": "workspace:*", }, @@ -417,11 +427,11 @@ }, "packages/logger": { "name": "@abapify/logger", - "version": "0.3.6", + "version": "0.4.1", }, "packages/openai-codegen": { "name": "@abapify/openai-codegen", - "version": "0.3.6", + "version": "0.4.1", "bin": { "openai-codegen": "./dist/cli.mjs", }, @@ -439,11 +449,11 @@ }, "packages/speci": { "name": "@abapify/speci", - "version": "0.3.6", + "version": "0.4.1", }, "packages/ts-xsd": { "name": "@abapify/ts-xsd", - "version": "0.3.6", + "version": "0.4.1", "bin": { "ts-xsd": "./dist/codegen/cli.mjs", }, @@ -492,7 +502,7 @@ }, "tools/p2-cli": { "name": "@abapify/p2-cli", - "version": "0.1.0", + "version": "0.4.1", "bin": { "p2": "./dist/cli.mjs", }, @@ -567,6 +577,8 @@ "@abapify/adt-plugin-gcts-cli": ["@abapify/adt-plugin-gcts-cli@workspace:packages/adt-plugin-gcts-cli"], + "@abapify/adt-proxy": ["@abapify/adt-proxy@workspace:packages/adt-proxy"], + "@abapify/adt-puppeteer": ["@abapify/adt-puppeteer@workspace:packages/adt-puppeteer"], "@abapify/adt-rfc": ["@abapify/adt-rfc@workspace:packages/adt-rfc"], @@ -4921,6 +4933,8 @@ "@abapify/adt-plugin-gcts-cli/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@abapify/adt-proxy/fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + "@abapify/adt-tui/fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], "@abapify/asjson-parser/jsonc-eslint-parser": ["jsonc-eslint-parser@2.4.2", "", { "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", "espree": "^9.0.0", "semver": "^7.3.5" } }, "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA=="], @@ -6107,6 +6121,10 @@ "@abapify/adt-playwright/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@abapify/adt-proxy/fast-xml-parser/path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + + "@abapify/adt-proxy/fast-xml-parser/strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="], + "@abapify/adt-tui/fast-xml-parser/path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], "@abapify/adt-tui/fast-xml-parser/strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="], diff --git a/packages/adt-proxy/src/proxy.ts b/packages/adt-proxy/src/proxy.ts index 9197dc8e..a3108005 100644 --- a/packages/adt-proxy/src/proxy.ts +++ b/packages/adt-proxy/src/proxy.ts @@ -114,7 +114,9 @@ function convertResponseBody( if (!body || !isXmlContentType(contentType) || !responseSchemas[status]) { return { body, converted: false }; } - return { body: xmlToJson(body, responseSchemas[status]), converted: true }; + const converted = xmlToJson(body, responseSchemas[status]); + const wasConverted = converted !== body; + return { body: converted, converted: wasConverted }; } async function forwardRequest( @@ -122,35 +124,44 @@ async function forwardRequest( url: string, headers: Record, body?: string, + timeoutMs = 30_000, ): Promise<{ status: number; headers: Record; body: string; }> { - const response = await fetch(url, { - method, - headers, - body: body || undefined, - }); - - const responseBody = await response.text(); - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - - if (typeof response.headers.getSetCookie === 'function') { - const setCookies = response.headers.getSetCookie(); - if (setCookies.length > 0) { - responseHeaders['set-cookie'] = setCookies; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + method, + headers, + body: body || undefined, + signal: controller.signal, + }); + + const responseBody = await response.text(); + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + if (typeof response.headers.getSetCookie === 'function') { + const setCookies = response.headers.getSetCookie(); + if (setCookies.length > 0) { + responseHeaders['set-cookie'] = setCookies; + } } - } - return { - status: response.status, - headers: responseHeaders, - body: responseBody, - }; + return { + status: response.status, + headers: responseHeaders, + body: responseBody, + }; + } finally { + clearTimeout(timer); + } } export function createAdtProxy(config: AdtProxyConfig) { @@ -323,14 +334,15 @@ export function createAdtProxy(config: AdtProxyConfig) { } async function readBody(req: { - on: (event: string, cb: (chunk: any) => void) => void; + on: (event: string, cb: (...args: any[]) => void) => void; }): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { let body = ''; req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); req.on('end', () => resolve(body)); + req.on('error', (err: Error) => reject(err)); }); } @@ -403,6 +415,8 @@ export function createAdtProxy(config: AdtProxyConfig) { }); }); + server.on('error', reject); + server.listen(config.port || 0, config.host || '127.0.0.1', () => { const addr = server?.address(); if (!addr || typeof addr !== 'object') { @@ -415,8 +429,6 @@ export function createAdtProxy(config: AdtProxyConfig) { logger.info(`Proxying to ${targetUrl}`); resolve({ port: addr.port }); }); - - server.on('error', reject); }); }, diff --git a/packages/speci/src/rest/server/create-server.ts b/packages/speci/src/rest/server/create-server.ts index 901699c0..1a46c0f3 100644 --- a/packages/speci/src/rest/server/create-server.ts +++ b/packages/speci/src/rest/server/create-server.ts @@ -129,10 +129,7 @@ function buildPathInfo( // Build regex by replacing sentinels with capture groups const escaped = resolvedPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regexStr = escaped.replaceAll( - new RegExp(PARAM_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), - '([^/]+)', - ); + const regexStr = escaped.split(PARAM_MARKER).join('([^/]+)'); // Build template by replacing sentinels with ${paramName} syntax let template = resolvedPath; From 06a20ae31c83dc5d3bcd10b5756477b5c8aa6a26 Mon Sep 17 00:00:00 2001 From: "Maple (gastown)" Date: Tue, 16 Jun 2026 23:51:04 +0000 Subject: [PATCH 06/11] fix: use bunx instead of npx in auth hint --- packages/adt-cli/src/lib/commands/proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adt-cli/src/lib/commands/proxy.ts b/packages/adt-cli/src/lib/commands/proxy.ts index db3635ba..8698ec92 100644 --- a/packages/adt-cli/src/lib/commands/proxy.ts +++ b/packages/adt-cli/src/lib/commands/proxy.ts @@ -53,7 +53,7 @@ export const proxyCommand = new Command('proxy') if (!session || !session.auth) { console.error('❌ Not authenticated and no --target specified'); console.error( - '💡 Run "npx adt auth login" or provide --target ', + '💡 Run "bunx adt auth login" or provide --target ', ); process.exit(1); } From 80a54bf4098bf8de5b8fd8b550e345afb6f02da4 Mon Sep 17 00:00:00 2001 From: "Birch (gastown)" Date: Tue, 16 Jun 2026 23:53:44 +0000 Subject: [PATCH 07/11] fix: address 3 blocking issues in ADT proxy rework - Security: Add configurable maxBodySize (default 10MB) to readBody to prevent OOM from arbitrarily large request bodies - Bug: Add req.on('error', reject) to readBody so stream errors properly reject the promise instead of hanging the server - Bug: Split route matching condition so matched routes always forward with contract-specific requestHeaders, even when convertContent=false Non-blocking cleanups: - Remove unused fast-xml-parser dependency - Remove unused AuthSession type import from proxy CLI - Remove unused paramCount parameter from buildPathInfo in createServer - Add --verbose and --max-body-size CLI options to expose new config --- packages/adt-cli/src/lib/commands/proxy.ts | 15 ++++++++- packages/adt-proxy/package.json | 3 +- packages/adt-proxy/src/proxy.ts | 33 +++++++++++++------ packages/adt-proxy/src/types.ts | 3 ++ .../speci/src/rest/server/create-server.ts | 3 -- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/packages/adt-cli/src/lib/commands/proxy.ts b/packages/adt-cli/src/lib/commands/proxy.ts index 8698ec92..bd789719 100644 --- a/packages/adt-cli/src/lib/commands/proxy.ts +++ b/packages/adt-cli/src/lib/commands/proxy.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { createAdtProxy } from '@abapify/adt-proxy'; -import { loadAuthSession, type AuthSession } from '../utils/auth'; +import { loadAuthSession } from '../utils/auth'; export const proxyCommand = new Command('proxy') .description('Start an ADT proxy server with JSON↔XML conversion') @@ -37,7 +37,19 @@ export const proxyCommand = new Command('proxy') '--no-forward-unknown', 'Return 404 for unmatched routes instead of forwarding', ) + .option( + '--max-body-size ', + 'Maximum request body size in bytes (default: 10485760)', + (val: string) => { + const parsed = parseInt(val, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error(`Invalid max-body-size: ${val}`); + } + return parsed; + }, + ) .option('--sid ', 'SAP System ID to use for authentication') + .option('--verbose', 'Enable debug logging') .action(async (options, _command) => { try { // Determine target URL and auth @@ -110,6 +122,7 @@ export const proxyCommand = new Command('proxy') basePath: options.basePath, convertContent: options.convert !== false, forwardUnknown: options.forwardUnknown !== false, + maxBodySize: options.maxBodySize, logger: { debug: (msg, obj) => { if (options.verbose) { diff --git a/packages/adt-proxy/package.json b/packages/adt-proxy/package.json index 0345bc21..fd988370 100644 --- a/packages/adt-proxy/package.json +++ b/packages/adt-proxy/package.json @@ -15,8 +15,7 @@ ], "dependencies": { "@abapify/adt-contracts": "0.4.1", - "@abapify/speci": "0.4.1", - "fast-xml-parser": "^5.5.3" + "@abapify/speci": "0.4.1" }, "repository": { "type": "git", diff --git a/packages/adt-proxy/src/proxy.ts b/packages/adt-proxy/src/proxy.ts index a3108005..16c089d0 100644 --- a/packages/adt-proxy/src/proxy.ts +++ b/packages/adt-proxy/src/proxy.ts @@ -37,6 +37,8 @@ import { } from './converter'; import type { AdtProxyConfig, ProxyResult, Logger } from './types'; +const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024; + const DEFAULT_LOGGER: Logger = { debug: () => {}, info: () => {}, @@ -172,6 +174,7 @@ export function createAdtProxy(config: AdtProxyConfig) { forwardUnknown = true, convertContent = true, defaultHeaders = {}, + maxBodySize = DEFAULT_MAX_BODY_SIZE, logger = DEFAULT_LOGGER, } = config; @@ -217,12 +220,14 @@ export function createAdtProxy(config: AdtProxyConfig) { requestBody: string, incomingHeaders: Record, route: RouteDefinition, + doConvert: boolean, ): Promise { logger.info(`Matched route: ${route.method} ${route.pathTemplate}`); const reqContentType = getContentType(incomingHeaders); - const { body: downstreamBody, converted: reqConverted } = - convertRequestBody(requestBody, reqContentType, route.bodySchema); + const { body: downstreamBody, converted: reqConverted } = doConvert + ? convertRequestBody(requestBody, reqContentType, route.bodySchema) + : { body: requestBody, converted: false }; const downstreamHeaders = buildDownstreamHeaders( incomingHeaders, @@ -242,13 +247,14 @@ export function createAdtProxy(config: AdtProxyConfig) { const respContentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'] || ''; - const { body: responseBody, converted: respConverted } = - convertResponseBody( - response.body, - respContentType, - response.status, - route.responseSchemas, - ); + const { body: responseBody, converted: respConverted } = doConvert + ? convertResponseBody( + response.body, + respContentType, + response.status, + route.responseSchemas, + ) + : { body: response.body, converted: false }; return { status: response.status, @@ -338,7 +344,13 @@ export function createAdtProxy(config: AdtProxyConfig) { }): Promise { return new Promise((resolve, reject) => { let body = ''; + let totalSize = 0; req.on('data', (chunk: Buffer) => { + totalSize += chunk.length; + if (totalSize > maxBodySize) { + reject(new Error(`Request body exceeds maximum size of ${maxBodySize} bytes`)); + return; + } body += chunk.toString(); }); req.on('end', () => resolve(body)); @@ -368,13 +380,14 @@ export function createAdtProxy(config: AdtProxyConfig) { const requestBody = await readBody(req); const match = contractRoutes.match(method, url, basePath); - if (match && convertContent) { + if (match) { const result = await handleMatchedRoute( method, url, requestBody, req.headers, match.route, + convertContent, ); sendResponse(res, result); return; diff --git a/packages/adt-proxy/src/types.ts b/packages/adt-proxy/src/types.ts index 4e3fda19..d2c9b10d 100644 --- a/packages/adt-proxy/src/types.ts +++ b/packages/adt-proxy/src/types.ts @@ -37,6 +37,9 @@ export interface AdtProxyConfig { /** Custom headers to add to all downstream requests */ defaultHeaders?: Record; + /** Maximum allowed request body size in bytes (default: 10MB) */ + maxBodySize?: number; + /** Logger instance (must implement debug, info, warn, error methods) */ logger?: Logger; } diff --git a/packages/speci/src/rest/server/create-server.ts b/packages/speci/src/rest/server/create-server.ts index 1a46c0f3..f6e0e6e6 100644 --- a/packages/speci/src/rest/server/create-server.ts +++ b/packages/speci/src/rest/server/create-server.ts @@ -114,7 +114,6 @@ function walkContract( */ function buildPathInfo( resolvedPath: string, - paramCount: number, ): { regex: RegExp; template: string; paramNames: string[] } { // Count sentinel occurrences to determine parameter count const sentinelCount = ( @@ -175,11 +174,9 @@ export function createServer>( const routes: RouteDefinition[] = endpoints.map( ({ operation, descriptor }) => { const resolvedPath = descriptor.path || ''; - const paramCount = (operation as OperationFunction).length || 0; const { regex, template, paramNames } = buildPathInfo( resolvedPath, - paramCount, ); // Extract body schema From c98ef9fd22bd2ea92fc4bde1ce06f1c72a38d473 Mon Sep 17 00:00:00 2001 From: "Maple (gastown)" Date: Tue, 16 Jun 2026 23:59:08 +0000 Subject: [PATCH 08/11] fix: address CI review feedback - security, reliability, and code quality - Add maxBodySize limit (10MB) to readBody to prevent OOM attacks - Fix route matching when convertContent=false: matched routes now forward contract requestHeaders instead of falling through - Remove unused fast-xml-parser dependency - Remove unused AuthSession import in proxy command - Add --verbose CLI option for debug logging - Refactor createServer.match to reduce cyclomatic complexity - Remove unused paramCount parameter from buildPathInfo --- packages/adt-proxy/src/proxy.ts | 70 ++++++++++++------- .../speci/src/rest/server/create-server.ts | 31 ++++---- 2 files changed, 56 insertions(+), 45 deletions(-) diff --git a/packages/adt-proxy/src/proxy.ts b/packages/adt-proxy/src/proxy.ts index 16c089d0..5f32873d 100644 --- a/packages/adt-proxy/src/proxy.ts +++ b/packages/adt-proxy/src/proxy.ts @@ -37,8 +37,6 @@ import { } from './converter'; import type { AdtProxyConfig, ProxyResult, Logger } from './types'; -const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024; - const DEFAULT_LOGGER: Logger = { debug: () => {}, info: () => {}, @@ -174,7 +172,6 @@ export function createAdtProxy(config: AdtProxyConfig) { forwardUnknown = true, convertContent = true, defaultHeaders = {}, - maxBodySize = DEFAULT_MAX_BODY_SIZE, logger = DEFAULT_LOGGER, } = config; @@ -220,14 +217,12 @@ export function createAdtProxy(config: AdtProxyConfig) { requestBody: string, incomingHeaders: Record, route: RouteDefinition, - doConvert: boolean, ): Promise { logger.info(`Matched route: ${route.method} ${route.pathTemplate}`); const reqContentType = getContentType(incomingHeaders); - const { body: downstreamBody, converted: reqConverted } = doConvert - ? convertRequestBody(requestBody, reqContentType, route.bodySchema) - : { body: requestBody, converted: false }; + const { body: downstreamBody, converted: reqConverted } = + convertRequestBody(requestBody, reqContentType, route.bodySchema); const downstreamHeaders = buildDownstreamHeaders( incomingHeaders, @@ -247,14 +242,13 @@ export function createAdtProxy(config: AdtProxyConfig) { const respContentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'] || ''; - const { body: responseBody, converted: respConverted } = doConvert - ? convertResponseBody( - response.body, - respContentType, - response.status, - route.responseSchemas, - ) - : { body: response.body, converted: false }; + const { body: responseBody, converted: respConverted } = + convertResponseBody( + response.body, + respContentType, + response.status, + route.responseSchemas, + ); return { status: response.status, @@ -339,21 +333,24 @@ export function createAdtProxy(config: AdtProxyConfig) { res.end(result.body); } - async function readBody(req: { - on: (event: string, cb: (...args: any[]) => void) => void; - }): Promise { + async function readBody( + req: { + on: (event: string, cb: (...args: any[]) => void) => void; + }, + maxSize = 10 * 1024 * 1024, + ): Promise { return new Promise((resolve, reject) => { - let body = ''; - let totalSize = 0; + let size = 0; + const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => { - totalSize += chunk.length; - if (totalSize > maxBodySize) { - reject(new Error(`Request body exceeds maximum size of ${maxBodySize} bytes`)); + size += chunk.length; + if (size > maxSize) { + reject(new Error(`Request body exceeds ${maxSize} byte limit`)); return; } - body += chunk.toString(); + chunks.push(chunk); }); - req.on('end', () => resolve(body)); + req.on('end', () => resolve(Buffer.concat(chunks).toString())); req.on('error', (err: Error) => reject(err)); }); } @@ -380,19 +377,38 @@ export function createAdtProxy(config: AdtProxyConfig) { const requestBody = await readBody(req); const match = contractRoutes.match(method, url, basePath); - if (match) { + if (match && convertContent) { const result = await handleMatchedRoute( method, url, requestBody, req.headers, match.route, - convertContent, ); sendResponse(res, result); return; } + if (match) { + const downstreamHeaders = buildDownstreamHeaders( + req.headers, + match.route.requestHeaders, + ); + const response = await forwardRequest( + method, + buildDownstreamUrl(url), + downstreamHeaders, + requestBody, + ); + sendResponse(res, { + status: response.status, + headers: response.headers, + body: response.body, + converted: false, + }); + return; + } + if (forwardUnknown) { const result = await handleForwardedRequest( method, diff --git a/packages/speci/src/rest/server/create-server.ts b/packages/speci/src/rest/server/create-server.ts index f6e0e6e6..516694d7 100644 --- a/packages/speci/src/rest/server/create-server.ts +++ b/packages/speci/src/rest/server/create-server.ts @@ -112,10 +112,11 @@ function walkContract( * // For path '/users/\x00/posts/\x00' with param names ['userId', 'postId'] * // Returns: /\/users\/([^/]+)\/posts\/([^/]+)/ */ -function buildPathInfo( - resolvedPath: string, -): { regex: RegExp; template: string; paramNames: string[] } { - // Count sentinel occurrences to determine parameter count +function buildPathInfo(resolvedPath: string): { + regex: RegExp; + template: string; + paramNames: string[]; +} { const sentinelCount = ( resolvedPath.match(new RegExp(PARAM_MARKER, 'g')) || [] ).length; @@ -175,9 +176,7 @@ export function createServer>( ({ operation, descriptor }) => { const resolvedPath = descriptor.path || ''; - const { regex, template, paramNames } = buildPathInfo( - resolvedPath, - ); + const { regex, template, paramNames } = buildPathInfo(resolvedPath); // Extract body schema const bodySchema = @@ -218,23 +217,19 @@ export function createServer>( url: string, basePath = '', ): { route: RouteDefinition; params: Record } | null { - // Strip query string and base path const pathname = url.split('?')[0]; - const relativePath = - basePath && pathname.startsWith(basePath) - ? pathname.slice(basePath.length) || '/' - : pathname; + let relativePath = pathname; + if (basePath && pathname.startsWith(basePath)) { + relativePath = pathname.slice(basePath.length) || '/'; + } + const targetMethod = method.toUpperCase(); for (const route of routes) { - const routeWithRegex = route as RouteDefinition & { _regex: RegExp }; + if (route.method !== targetMethod) continue; - // Skip if method doesn't match - if (route.method !== method.toUpperCase()) continue; - - // Try to match the path + const routeWithRegex = route as RouteDefinition & { _regex: RegExp }; const match = relativePath.match(routeWithRegex._regex); if (match) { - // Build params object from capture groups const params: Record = {}; for (let i = 0; i < route.pathParamNames.length; i++) { params[route.pathParamNames[i]] = match[i + 1] || ''; From 4aa4b500a7e5b0aeead3feb18c87ac1e56b30708 Mon Sep 17 00:00:00 2001 From: "Maple (gastown)" Date: Wed, 17 Jun 2026 00:20:21 +0000 Subject: [PATCH 09/11] fix: address CI failures - remove dynamic RegExp, fix lint errors, add NX Cloud fallback - Replace dynamic RegExp in buildDownstreamUrl with startsWith/slice (Codacy security) - Replace new RegExp(PARAM_MARKER, 'g') with split() in buildPathInfo (Codacy security) - Fix empty function lint errors in DEFAULT_LOGGER - Add --no-cloud fallback in CI for fork PRs without NX_CLOUD_ACCESS_TOKEN --- .github/workflows/ci.yml | 5 ++++- packages/adt-proxy/src/proxy.ts | 18 ++++++++++-------- .../speci/src/rest/server/create-server.ts | 4 +--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3086047..f42fe362 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,5 +51,8 @@ jobs: shell: bash - run: npx nx format:check - run: npx nx affected -t lint test build e2e-ci --verbose=false --parallel=3 + if: ${{ env.NX_CLOUD_ACCESS_TOKEN != '' }} + - run: npx nx affected -t lint test build e2e-ci --verbose=false --parallel=3 --no-cloud + if: ${{ env.NX_CLOUD_ACCESS_TOKEN == '' }} - run: npx nx-cloud fix-ci - if: always() + if: always() && env.NX_CLOUD_ACCESS_TOKEN != '' diff --git a/packages/adt-proxy/src/proxy.ts b/packages/adt-proxy/src/proxy.ts index 5f32873d..5a8df440 100644 --- a/packages/adt-proxy/src/proxy.ts +++ b/packages/adt-proxy/src/proxy.ts @@ -38,8 +38,12 @@ import { import type { AdtProxyConfig, ProxyResult, Logger } from './types'; const DEFAULT_LOGGER: Logger = { - debug: () => {}, - info: () => {}, + debug: (_msg: string) => { + /* noop */ + }, + info: (_msg: string) => { + /* noop */ + }, warn: console.warn, error: console.error, }; @@ -195,12 +199,10 @@ export function createAdtProxy(config: AdtProxyConfig) { } function buildDownstreamUrl(url: string): string { - const relativePath = basePath - ? url.replace( - new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), - '', - ) || '/' - : url; + const relativePath = + basePath && url.startsWith(basePath) + ? url.slice(basePath.length) || '/' + : url; return `${targetUrl}${relativePath}`; } diff --git a/packages/speci/src/rest/server/create-server.ts b/packages/speci/src/rest/server/create-server.ts index 516694d7..b91371e6 100644 --- a/packages/speci/src/rest/server/create-server.ts +++ b/packages/speci/src/rest/server/create-server.ts @@ -117,9 +117,7 @@ function buildPathInfo(resolvedPath: string): { template: string; paramNames: string[]; } { - const sentinelCount = ( - resolvedPath.match(new RegExp(PARAM_MARKER, 'g')) || [] - ).length; + const sentinelCount = resolvedPath.split(PARAM_MARKER).length - 1; // Generate parameter names (p1, p2, ... if we don't know the real names) const paramNames = Array.from( From d779c4de070cbd4ad2da5ee9f84579d57e22bcff Mon Sep 17 00:00:00 2001 From: "Birch (gastown)" Date: Wed, 17 Jun 2026 08:02:22 +0000 Subject: [PATCH 10/11] fix(lint): use const for defaultHeaders in proxy command --- packages/adt-cli/src/lib/commands/proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adt-cli/src/lib/commands/proxy.ts b/packages/adt-cli/src/lib/commands/proxy.ts index bd789719..5feeda94 100644 --- a/packages/adt-cli/src/lib/commands/proxy.ts +++ b/packages/adt-cli/src/lib/commands/proxy.ts @@ -57,7 +57,7 @@ export const proxyCommand = new Command('proxy') let auth: | { username: string; password: string; client?: string } | undefined; - let defaultHeaders: Record = {}; + const defaultHeaders: Record = {}; if (!targetUrl) { // Use current auth session From 937754a4d8bec924ad1788236bd43e57c8a70781 Mon Sep 17 00:00:00 2001 From: "Maple (gastown)" Date: Wed, 17 Jun 2026 08:20:13 +0000 Subject: [PATCH 11/11] fix(proxy): skip set-cookie in forEach loop to prevent header flattening The `response.headers.forEach` callback joins multiple Set-Cookie header values with commas, which corrupts cookies whose Expires attributes contain commas (e.g. `Wdy, DD-Mon-YYYY HH:MM:SS GMT`). The subsequent `getSetCookie()` block only overwrites the flattened string when at least one cookie is present, so single-cookie responses are unaffected but the corruption still leaks through if getSetCookie is unavailable. Skip set-cookie in the forEach loop entirely so the dedicated getSetCookie branch is the only source of set-cookie values. --- packages/adt-proxy/src/proxy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/adt-proxy/src/proxy.ts b/packages/adt-proxy/src/proxy.ts index 5a8df440..5b3b7b8d 100644 --- a/packages/adt-proxy/src/proxy.ts +++ b/packages/adt-proxy/src/proxy.ts @@ -148,6 +148,7 @@ async function forwardRequest( const responseBody = await response.text(); const responseHeaders: Record = {}; response.headers.forEach((value, key) => { + if (key.toLowerCase() === 'set-cookie') return; responseHeaders[key] = value; });