import JSONBig from "json-bigint" import { isPlainObject } from "@zachsents/zippy" import * as z from "zod" import type { IntegrationScopeRequirement, ResolvedIntegrationAccount, } from "../../../automation/integrations" import { parseRetryAfter, retryableActionError, terminalActionError, } from "../../../automation/actions" const APOLLO_API_BASE_URL = "https://api.apollo.io/api/v1/" const APOLLO_API_ORIGIN = new URL(APOLLO_API_BASE_URL).origin const APOLLO_API_PATH_PREFIX = new URL(APOLLO_API_BASE_URL).pathname const APOLLO_JSON = JSONBig({ storeAsString: true }) const APOLLO_API_KEY_SECRET_SCHEMA = z.object({ apiKey: z.string().min(1), }) const APOLLO_OAUTH_SECRET_SCHEMA = z.object({ accessToken: z.string().min(1), expiresAt: z.number().int().positive(), refreshToken: z.string().min(1), tokenType: z.string().min(1), }) const APOLLO_ERROR_SCHEMA = z.looseObject({ error: z.string().optional(), error_code: z.union([z.string(), z.number()]).optional(), error_message: z.string().optional(), message: z.string().optional(), retry_after_seconds: z.number().nonnegative().optional(), }) /** Scalar or repeated value serialized into Apollo query parameters. */ export type ApolloQueryValue = | boolean | number | readonly (boolean | number | string)[] | string | undefined /** Options for one authenticated Apollo REST request. */ export interface ApolloRequestOptions { /** Provider-native JSON request body. */ body?: unknown /** Additional request headers. Apollo authentication is always overridden. */ headers?: ConstructorParameters[0] /** HTTP method. Defaults to `POST` when a body is present and `GET` otherwise. */ method?: "DELETE" | "GET" | "PATCH" | "POST" | "PUT" /** Provider-native query parameters. Arrays become repeated parameters. */ query?: Record /** Schema that validates and types the successful response. */ responseSchema: TSchema } /** Authenticated Apollo REST API helper for custom actions. */ export interface ApolloApi { /** * Calls an Apollo v1 endpoint and validates its successful response. * * @param path - Relative path below Apollo's `/api/v1` root. * @param options - Request data and successful response schema. */ request( path: string, options: ApolloRequestOptions, ): Promise> } /** Structured error returned by an Apollo API request. */ export class ApolloApiError extends Error { /** Provider-specific error code, when supplied. */ readonly providerCode?: number | string /** Provider error message, when supplied. */ readonly providerMessage?: string /** Retry delay reported by Apollo, in seconds. */ readonly retryAfter?: number /** HTTP status returned by Apollo. */ readonly status: number /** * Creates a structured Apollo API error. * * @param options - Provider and transport error details. * @param options.providerCode - Provider-specific error code. * @param options.providerMessage - Provider error message. * @param options.retryAfter - Retry delay in seconds. * @param options.status - HTTP response status. */ constructor(options: { providerCode?: number | string providerMessage?: string retryAfter?: number status: number }) { super( options.providerMessage ? `Apollo API error (${options.status}): ${options.providerMessage}` : `Apollo API request failed with status ${options.status}.`, ) this.name = "ApolloApiError" this.providerCode = options.providerCode this.providerMessage = options.providerMessage this.retryAfter = options.retryAfter this.status = options.status } } /** * Allows an Apollo action through OAuth with its required scope or an API key. * * @param requiredScope - OAuth scope requirement for the action. */ export function apolloAccountOptions( requiredScope: IntegrationScopeRequirement, ) { return { connections: [ { connectionMethodId: "oauth", requiredScope }, { connectionMethodId: "api-key" }, ], } as const } /** Allows an Apollo action only through an API key. */ export function apolloApiKeyAccountOptions() { return { connections: [{ connectionMethodId: "api-key" }] } as const } /** * Converts provider-native Apollo response keys to camelCase recursively. * * @param value - Provider response value. */ export function fromApolloWire(value: unknown): unknown { if (Array.isArray(value)) return value.map(fromApolloWire) if (!isPlainObject(value)) return value return Object.fromEntries( Object.entries(value).map(([key, item]) => [ key.replace(/_([a-z0-9])/g, (_, character: string) => character.toUpperCase(), ), fromApolloWire(item), ]), ) } /** * Creates a raw authenticated Apollo REST API helper. * * Use this escape hatch for endpoints without packaged actions. Callers supply * a response schema so provider results remain validated at the boundary. * * @param account - Resolved Apollo integration account. * @throws {Error} When the account uses an unsupported connection method. */ export function getApolloApi( account: ResolvedIntegrationAccount<"apollo">, ): ApolloApi { let authentication: { name: "Authorization" | "x-api-key"; value: string } if (account.connectionMethodId === "oauth") { const { accessToken } = APOLLO_OAUTH_SECRET_SCHEMA.parse(account.secret) authentication = { name: "Authorization", value: `Bearer ${accessToken}` } } else if (account.connectionMethodId === "api-key") { const { apiKey } = APOLLO_API_KEY_SECRET_SCHEMA.parse(account.secret) authentication = { name: "x-api-key", value: apiKey } } else { throw new Error( `Unsupported Apollo connection method: ${account.connectionMethodId}`, ) } return { request: async (path, options) => { const url = new URL(path.replace(/^\//, ""), APOLLO_API_BASE_URL) if ( url.origin !== APOLLO_API_ORIGIN || !url.pathname.startsWith(APOLLO_API_PATH_PREFIX) ) { throw new Error("Apollo API paths must use the Apollo v1 API origin.") } for (const [name, value] of Object.entries(options.query ?? {})) { if (Array.isArray(value)) { for (const item of value) url.searchParams.append(name, String(item)) } else if (value !== undefined) { url.searchParams.set(name, String(value)) } } const headers = new Headers(options.headers) headers.set("Accept", "application/json") headers.delete("Authorization") headers.delete("x-api-key") headers.set(authentication.name, authentication.value) if (options.body !== undefined) { headers.set("Content-Type", "application/json") } const response = await fetch(url, { body: options.body === undefined ? undefined : JSON.stringify(options.body), headers, method: options.method ?? (options.body === undefined ? "GET" : "POST"), }) const responseText = await response.text() const payload = parseJson(responseText) if (!response.ok) { const error = APOLLO_ERROR_SCHEMA.safeParse(payload) const headerRetryAt = parseRetryAfter( response.headers.get("Retry-After"), ) const retryAt = error.success && error.data.retry_after_seconds !== undefined ? new Date(Date.now() + error.data.retry_after_seconds * 1_000) : headerRetryAt const apiError = new ApolloApiError({ providerCode: error.success ? error.data.error_code : undefined, providerMessage: error.success ? (error.data.error_message ?? error.data.message ?? error.data.error ?? (responseText.trim() || undefined)) : responseText.trim() || undefined, retryAfter: (error.success ? error.data.retry_after_seconds : undefined) ?? (headerRetryAt ? Math.max( 0, Math.ceil((headerRetryAt.getTime() - Date.now()) / 1_000), ) : undefined), status: response.status, }) if (response.status === 429) { throw retryableActionError(apiError, { retryAt }) } if (response.status < 500) { throw terminalActionError(apiError) } throw apiError } return options.responseSchema.parse(payload) }, } } /** * Parses an optional provider JSON body without masking its HTTP status. * * @param value - Raw provider response text. */ function parseJson(value: string): unknown { if (!value) return undefined try { return APOLLO_JSON.parse(value) } catch { return value } }