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/bun.lock b/bun.lock index e62683da..c9cd49da 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "abapify", @@ -81,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", @@ -177,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", @@ -200,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", @@ -244,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", @@ -288,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", @@ -303,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:*", @@ -316,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:*", @@ -365,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", @@ -394,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:*", }, @@ -416,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", }, @@ -438,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", }, @@ -491,7 +502,7 @@ }, "tools/p2-cli": { "name": "@abapify/p2-cli", - "version": "0.1.0", + "version": "0.4.1", "bin": { "p2": "./dist/cli.mjs", }, @@ -566,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"], @@ -4920,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=="], @@ -6106,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-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..5feeda94 --- /dev/null +++ b/packages/adt-cli/src/lib/commands/proxy.ts @@ -0,0 +1,186 @@ +import { Command } from 'commander'; +import { createAdtProxy } from '@abapify/adt-proxy'; +import { loadAuthSession } 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)', + (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 ', + '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( + '--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 + let targetUrl: string | undefined = options.target; + let auth: + | { username: string; password: string; client?: string } + | undefined; + const defaultHeaders: Record = {}; + + if (!targetUrl) { + // Use current auth session + const session = loadAuthSession(options.sid); + if (!session || !session.auth) { + console.error('❌ Not authenticated and no --target specified'); + console.error( + '💡 Run "bunx 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') { + const creds = session.auth.credentials as { cookies: string }; + 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; + } + } + } + + 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, + defaultHeaders, + port: options.port, + host: options.host, + basePath: options.basePath, + convertContent: options.convert !== false, + forwardUnknown: options.forwardUnknown !== false, + maxBodySize: options.maxBodySize, + 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..fd988370 --- /dev/null +++ b/packages/adt-proxy/package.json @@ -0,0 +1,29 @@ +{ + "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" + }, + "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..3d1ecaaf --- /dev/null +++ b/packages/adt-proxy/src/converter.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ +export function jsonToXml(json: string, schema: Serializable): string { + try { + const data = JSON.parse(json); + if (typeof schema.build === 'function') { + return schema.build(data); + } + return json; + } catch { + return json; + } +} + +/** + * Convert an XML string to JSON using a schema's parse method. + */ +export function xmlToJson(xml: string, schema: Serializable): string { + try { + const data = schema.parse(xml); + return JSON.stringify(data); + } catch { + return xml; + } +} + +/** + * Detect content type from a string or content-type header. + */ +export function detectContentType( + content: string, + contentType?: string, +): 'json' | 'xml' | 'text' | 'binary' { + if (contentType) { + const ct = contentType.toLowerCase(); + if (ct.includes('json')) return 'json'; + if (ct.includes('xml')) return 'xml'; + if (ct.includes('text')) return 'text'; + return 'binary'; + } + + const trimmed = content.trimStart(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 'json'; + if (trimmed.startsWith(' { + /* noop */ + }, + info: (_msg: string) => { + /* noop */ + }, + warn: console.warn, + error: console.error, +}; + +const FORWARDABLE_REQUEST_HEADERS = [ + 'accept', + 'content-type', + 'x-csrf-token', + 'x-sap-security-session', + 'x-sap-adt-sessiontype', + 'cookie', +]; + +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] = 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, + status: number, + responseSchemas: RouteDefinition['responseSchemas'], +): { body: string; converted: boolean } { + if (!body || !isXmlContentType(contentType) || !responseSchemas[status]) { + return { body, converted: false }; + } + const converted = xmlToJson(body, responseSchemas[status]); + const wasConverted = converted !== body; + return { body: converted, converted: wasConverted }; +} + +async function forwardRequest( + method: string, + url: string, + headers: Record, + body?: string, + timeoutMs = 30_000, +): Promise<{ + status: number; + headers: Record; + body: string; +}> { + 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) => { + if (key.toLowerCase() === 'set-cookie') return; + 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, + }; + } finally { + clearTimeout(timer); + } +} + +export function createAdtProxy(config: AdtProxyConfig) { + const { + targetUrl, + auth, + basePath = '', + forwardUnknown = true, + convertContent = true, + defaultHeaders = {}, + logger = DEFAULT_LOGGER, + } = config; + + const contractRoutes = createServer(adtContract as AdtContract); + let server: Server | undefined; + + 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; + } + + function buildDownstreamUrl(url: string): string { + const relativePath = + basePath && url.startsWith(basePath) + ? url.slice(basePath.length) || '/' + : url; + return `${targetUrl}${relativePath}`; + } + + function getContentType( + headers: Record, + ): string | undefined { + const ct = headers['content-type']; + return Array.isArray(ct) ? ct[0] : ct; + } + + async function handleMatchedRoute( + method: string, + url: string, + requestBody: string, + incomingHeaders: Record, + 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'; + } + + const response = await forwardRequest( + method, + buildDownstreamUrl(url), + downstreamHeaders, + downstreamBody, + ); + + 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, + ); + + return { + status: response.status, + headers: response.headers, + body: responseBody, + converted: respConverted, + }; + } + + async function handleForwardedRequest( + method: string, + url: string, + requestBody: string, + incomingHeaders: Record, + ): Promise { + logger.debug(`No route match for ${method} ${url} - forwarding as-is`); + + const downstreamHeaders = buildDownstreamHeaders(incomingHeaders); + const response = await forwardRequest( + method, + buildDownstreamUrl(url), + downstreamHeaders, + requestBody, + ); + + return { + status: response.status, + headers: response.headers, + body: response.body, + converted: false, + }; + } + + 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: (...args: any[]) => void) => void; + }, + maxSize = 10 * 1024 * 1024, + ): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > maxSize) { + reject(new Error(`Request body exceeds ${maxSize} byte limit`)); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString())); + req.on('error', (err: Error) => reject(err)); + }); + } + + 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}`); + + const requestBody = await readBody(req); + const match = contractRoutes.match(method, url, basePath); + + if (match && convertContent) { + const result = await handleMatchedRoute( + method, + url, + requestBody, + req.headers, + match.route, + ); + 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, + url, + requestBody, + req.headers, + ); + sendResponse(res, result); + return; + } + + sendNotFound(res, method, url); + } + + return { + get routes() { + return contractRoutes.routes; + }, + match: contractRoutes.match, + + 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.on('error', reject); + + 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 }); + }); + }); + }, + + async stop(): Promise { + return new Promise((resolve, reject) => { + if (!server) return resolve(); + server.close((err) => (err ? reject(err) : resolve())); + }); + }, + }; +} + +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..d2c9b10d --- /dev/null +++ b/packages/adt-proxy/src/types.ts @@ -0,0 +1,92 @@ +/** + * 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; + + /** Maximum allowed request body size in bytes (default: 10MB) */ + maxBodySize?: number; + + /** 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..b91371e6 --- /dev/null +++ b/packages/speci/src/rest/server/create-server.ts @@ -0,0 +1,242 @@ +/** + * 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 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 }> { + if (typeof obj === 'function') { + const result = tryExtractEndpoint(obj as OperationFunction); + return result ? [result] : []; + } + + 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 []; +} + +/** + * 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): { + regex: RegExp; + template: string; + paramNames: string[]; +} { + 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( + { length: sentinelCount }, + (_, i) => `p${i + 1}`, + ); + + // Build regex by replacing sentinels with capture groups + const escaped = resolvedPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regexStr = escaped.split(PARAM_MARKER).join('([^/]+)'); + + // 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 { regex, template, paramNames } = buildPathInfo(resolvedPath); + + // 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 { + const pathname = url.split('?')[0]; + let relativePath = pathname; + if (basePath && pathname.startsWith(basePath)) { + relativePath = pathname.slice(basePath.length) || '/'; + } + + const targetMethod = method.toUpperCase(); + for (const route of routes) { + if (route.method !== targetMethod) continue; + + const routeWithRegex = route as RouteDefinition & { _regex: RegExp }; + const match = relativePath.match(routeWithRegex._regex); + if (match) { + 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..86c2c4cb --- /dev/null +++ b/packages/speci/src/rest/server/types.ts @@ -0,0 +1,113 @@ +/** + * Speci REST - Server Types + * + * Framework-agnostic types for generating servers from REST contracts. + */ + +import type { Serializable, RestEndpointDescriptor } 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', });