import { clearCachedOAuthToken, getCachedOAuthToken } from "./oauth-token-cache"; export type TXsuaaTokenCredential = { clientId: string; clientSecret: string; url: string }; /** * None of this file's `fetch` calls had a timeout — confirmed as a real bug: a call whose target * hangs (e.g. `getObjectOnPremiseData`-style function imports that reach out via SAP Cloud * Connector to an on-premise system that's slow or unreachable) left the Check API External UI * spinning forever with zero feedback, indistinguishable from "still working." Every call below * now bounds itself with this timeout and reports a clear, specific error instead. */ const DEFAULT_REQUEST_TIMEOUT_MS = 45_000; /** Wraps a `fetch` rejection so a timeout reads as an actionable message instead of a bare `TimeoutError` `DOMException`. */ async function fetchWithTimeout(url: string | URL, init: RequestInit, timeoutMs: number, describe: string): Promise { try { return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); } catch (error) { if (error instanceof Error && error.name === "TimeoutError") { throw new Error(`${describe} timed out after ${Math.round(timeoutMs / 1000)}s — the app/service may be slow to respond or unreachable (e.g. an on-premise call via Cloud Connector that never returns).`); } throw error; } } async function requestXsuaaAccessToken(credential: TXsuaaTokenCredential, timeoutMs: number): Promise<{ token: string; expiresInSeconds?: number }> { const tokenUrl = `${credential.url.replace(/\/+$/, "")}/oauth/token`; const response = await fetchWithTimeout( tokenUrl, { method: "POST", headers: { authorization: `Basic ${Buffer.from(`${credential.clientId}:${credential.clientSecret}`).toString("base64")}`, "content-type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "client_credentials" }), }, timeoutMs, "XSUAA token request", ); const json = (await response.json().catch(() => ({}))) as { access_token?: string; expires_in?: number; error_description?: string }; if (!response.ok || !json.access_token) { throw new Error(json.error_description || `XSUAA token request failed (HTTP ${response.status})`); } return { token: json.access_token, expiresInSeconds: json.expires_in }; } function xsuaaCacheKey(credential: TXsuaaTokenCredential): string { return `xsuaa|${credential.url}|${credential.clientId}`; } /** Standard XSUAA client-credentials OAuth2 grant — cached per (url, clientId) until near expiry. */ export async function fetchXsuaaAccessToken(credential: TXsuaaTokenCredential, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): Promise { return getCachedOAuthToken(xsuaaCacheKey(credential), () => requestXsuaaAccessToken(credential, timeoutMs)); } export type TCallCapApiOptions = { credential: TXsuaaTokenCredential; /** The service's real, live-resolved base URL (e.g. `https://simplemdg-srv-bp.cfapps.us10.hana.ondemand.com`) — see cds-service-discovery.ts's `cfAppName` cross-referenced against a live `cf apps` listing. There is no reliable naming convention to reconstruct this from region/space/service-key alone (confirmed empirically: no mta.yaml/manifest.yml exists in a real customer's repos, so a customer's actual CF route is whatever `cf push ` defaulted to, not a fixed pattern). */ baseUrl: string; path: string; method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; /** Arbitrary query params — `$select`/`$expand`/`$filter`/`$orderby`/`$top`/`$skip`/`$count`/`$inlinecount`, function-import params, etc. Empty-string values are dropped. */ queryParams?: Record; body?: unknown; /** Longer default than the other calls here — this is the one most likely to hit a genuinely slow function import (e.g. one backed by an on-premise call via Cloud Connector), not just an internal CAP round trip. */ timeoutMs?: number; }; export type TCallCapApiResult = { status: number; ok: boolean; body: unknown; url: string }; const CALL_CAP_API_TIMEOUT_MS = 90_000; /** Proxies the actual authenticated call server-side, avoiding CORS from the browser (same reason the legacy tool did this server-side). */ export async function callCapApi(options: TCallCapApiOptions): Promise { const timeoutMs = options.timeoutMs ?? CALL_CAP_API_TIMEOUT_MS; const base = options.baseUrl.replace(/\/+$/, ""); const url = new URL(`${base}${options.path.startsWith("/") ? "" : "/"}${options.path}`); for (const [key, value] of Object.entries(options.queryParams ?? {})) { if (value) url.searchParams.set(key, value); } const attempt = async (): Promise => { const token = await fetchXsuaaAccessToken(options.credential, timeoutMs); const response = await fetchWithTimeout( url, { method: options.method ?? "GET", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, }, timeoutMs, `${options.method ?? "GET"} ${options.path}`, ); const text = await response.text(); let body: unknown = text; try { body = text ? JSON.parse(text) : undefined; } catch { body = text; } return { status: response.status, ok: response.ok, body, url: url.toString() }; }; const result = await attempt(); if (result.status !== 401) return result; // A cached-but-now-stale token (the saved BTP credential was rotated/revoked, or clock skew // slipped past our TTL margin) used to keep getting handed out until its self-reported TTL // elapsed — every call in between failed with a bare 401 and no hint why. Drop it and retry // once with a freshly fetched token before giving up. clearCachedOAuthToken(xsuaaCacheKey(options.credential)); return attempt(); } /** Fetches the raw `$metadata` EDMX document for a resolved CAP service — parsed by odata-metadata-parser.ts into entity sets/types/function imports. */ export async function fetchODataMetadataXml(options: { credential: TXsuaaTokenCredential; baseUrl: string; path: string }): Promise { const base = options.baseUrl.replace(/\/+$/, ""); const servicePath = options.path.startsWith("/") ? options.path : `/${options.path}`; const metadataUrl = `${base}${servicePath}/$metadata`; const attempt = async (): Promise => { const token = await fetchXsuaaAccessToken(options.credential); return fetchWithTimeout(metadataUrl, { headers: { authorization: `Bearer ${token}` } }, DEFAULT_REQUEST_TIMEOUT_MS, "$metadata request"); }; let response = await attempt(); if (response.status === 401) { clearCachedOAuthToken(xsuaaCacheKey(options.credential)); response = await attempt(); } if (!response.ok) { if (response.status === 401) { throw new Error("$metadata request failed (HTTP 401) — the saved BTP credential for this app may be stale. Remove and re-import it from the BTP Credentials page."); } throw new Error(`$metadata request failed (HTTP ${response.status})`); } return await response.text(); } export type TLiveDiscoveredService = { name: string; path: string }; /** * CAP mounts a default index at a service's own root (`GET /`) listing every OData service bound * in that app — with a JSON `Accept` header some CAP versions return a machine-readable list * (`{ "value": [{ "name", "url" }, ...] }` or a bare array); otherwise it's an HTML "welcome" page * with an `` per service. Tried FIRST (see check-api-routes.ts) because it needs * nothing but the app's own live route + a valid token — no GitLab access, no source-scanning * heuristics. Not guaranteed: some CAP versions/configs disable this index outright (particularly * in production), so a `undefined` return here is expected and normal, not an error — callers fall * back to scanning the repo's `.cds` sources instead. */ export async function discoverServicesViaLiveIndex(credential: TXsuaaTokenCredential, baseUrl: string): Promise { const token = await fetchXsuaaAccessToken(credential); const response = await fetchWithTimeout( `${baseUrl.replace(/\/+$/, "")}/`, { headers: { authorization: `Bearer ${token}`, accept: "application/json, text/html" } }, DEFAULT_REQUEST_TIMEOUT_MS, "Live service index request", ); if (!response.ok) return undefined; const contentType = response.headers.get("content-type") ?? ""; const text = await response.text(); if (contentType.includes("json")) { try { const parsed = JSON.parse(text) as unknown; const entries = Array.isArray(parsed) ? parsed : Array.isArray((parsed as { value?: unknown[] })?.value) ? (parsed as { value: unknown[] }).value : undefined; const services = (entries ?? []) .filter((entry): entry is { name: string; url?: string } => Boolean(entry) && typeof (entry as { name?: unknown }).name === "string") .map((entry) => ({ name: entry.name, path: typeof entry.url === "string" && entry.url ? entry.url : `/${entry.name}` })); if (services.length) return services; } catch { // Not actually JSON despite the content-type — fall through to HTML link-scraping below. } } // CAP's default HTML welcome page links each mounted service as e.g. ``. const hrefs = Array.from(text.matchAll(/href="([^"]+)"/g)).map((match) => match[1]); const seen = new Set(); const services: TLiveDiscoveredService[] = []; for (const href of hrefs) { const path = href.split("?")[0].replace(/\/$/, ""); if (!/^\/[A-Za-z_][\w./-]*$/.test(path) || path.includes("$metadata") || path.includes("//") || seen.has(path)) continue; seen.add(path); services.push({ name: path.replace(/^\//, ""), path }); } return services.length ? services : undefined; }