import { hubspotFetch, resolveHubSpotAuth, type ResolvedHubSpotAuth } from './auth.ts'
import type { HubSpotAuthInput, HubSpotPageInfo, JsonRecord } from './types.ts'
import { requireString } from './types.ts'
import {
HUBSPOT_API_BASE_URL,
HUBSPOT_REQUIRED_HOST,
inferOperationFromPath,
isMutatingMethod,
nextStepForScope,
scopeForOperation,
type HubSpotOperation,
} from './setup.ts'
const ALLOWED_HOSTS = new Set(['api.hubapi.com', 'api.hubspot.com'])
export class HubSpotApiError extends Error {
readonly status: number
readonly operation: HubSpotOperation
readonly details: unknown
readonly missingScope: string | null
constructor(
message: string,
options: {
status: number
operation: HubSpotOperation
details: unknown
missingScope: string | null
},
) {
super(message)
this.name = 'HubSpotApiError'
this.status = options.status
this.operation = options.operation
this.details = options.details
this.missingScope = options.missingScope
}
}
export type HubSpotRequestInput = HubSpotAuthInput & {
path: string
method?: string
query?: JsonRecord
body?: JsonRecord
operation?: HubSpotOperation
}
export type HubSpotResponse<T> = {
data: T
pageInfo: HubSpotPageInfo
status: number
}
function hubspotErrorMessage(body: unknown): string | null {
if (!body || typeof body !== 'object') return null
const record = body as { message?: unknown; category?: unknown }
if (typeof record.message === 'string' && record.message.length > 0) {
return record.message
}
return null
}
function looksLikeInsufficientScope(status: number, body: unknown): boolean {
if (status === 401 || status === 403) return true
const message = (hubspotErrorMessage(body) ?? '').toLowerCase()
const category =
body && typeof body === 'object' && typeof (body as { category?: unknown }).category === 'string'
? String((body as { category: string }).category).toLowerCase()
: ''
return (
message.includes('missing scope') ||
message.includes('insufficient') ||
message.includes('not authorized') ||
message.includes('forbidden') ||
category.includes('permission') ||
category.includes('authorization')
)
}
export function normalizeHubSpotPath(path: string): string {
const trimmed = requireString(path, 'path')
if (trimmed.startsWith('https://')) {
const url = new URL(trimmed)
if (!ALLOWED_HOSTS.has(url.host)) {
throw new Error(
`HubSpot requests must use host ${HUBSPOT_REQUIRED_HOST} (or api.hubspot.com). Got ${url.host}.`,
)
}
return `${url.pathname}${url.search}`
}
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
}
export function hubspotUrl(path: string, query?: JsonRecord): string {
const normalized = normalizeHubSpotPath(path)
const url = new URL(normalized, `${HUBSPOT_API_BASE_URL}/`)
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue
if (typeof value === 'boolean' || typeof value === 'number') {
url.searchParams.set(key, String(value))
continue
}
if (typeof value === 'string') {
url.searchParams.set(key, value)
continue
}
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
url.searchParams.set(key, value.join(','))
continue
}
throw new Error(`query.${key} must be a string, number, boolean, or string array.`)
}
}
return url.toString()
}
export async function hubspotRequest<T = unknown>(
input: HubSpotRequestInput,
): Promise<HubSpotResponse<T>> {
const method = (input.method ?? 'GET').toUpperCase()
const path = requireString(input.path, 'path')
const operation = inferOperationFromPath(method, normalizeHubSpotPath(path), input.operation)
const auth = await resolveHubSpotAuth(input)
return hubspotRequestWithAuth<T>(auth, {
method,
path,
query: input.query,
body: input.body,
operation,
})
}
export async function hubspotRequestWithAuth<T = unknown>(
auth: ResolvedHubSpotAuth,
input: {
method: string
path: string
query?: JsonRecord
body?: JsonRecord
operation: HubSpotOperation
},
): Promise<HubSpotResponse<T>> {
const url = hubspotUrl(input.path, input.query)
const headers = new Headers({
Accept: 'application/json',
'User-Agent': 'kody-hubspot/1.0',
})
const init: RequestInit = { method: input.method, headers }
if (input.body !== undefined) {
headers.set('Content-Type', 'application/json')
init.body = JSON.stringify(input.body)
}
const response = await hubspotFetch(auth, url, init)
const text = await response.text()
let parsed: unknown = text
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = text
}
if (!response.ok || looksLikeInsufficientScope(response.status, parsed)) {
const missingScope = scopeForOperation(input.operation)
const firstMessage =
hubspotErrorMessage(parsed) ||
(typeof parsed === 'string' ? parsed.slice(0, 400) : response.statusText)
throw new HubSpotApiError(
[
`HubSpot ${input.operation} failed (${response.status}): ${firstMessage}.`,
`This call needs the "${missingScope}" permission.`,
nextStepForScope(missingScope, {
authMode: auth.mode,
integrationName: auth.integrationName,
secretName: auth.secretName,
}),
].join(' '),
{
status: response.status,
operation: input.operation,
details: parsed,
missingScope,
},
)
}
const record = parsed && typeof parsed === 'object' ? (parsed as JsonRecord) : {}
const paging =
record.paging && typeof record.paging === 'object'
? (record.paging as { next?: { after?: unknown } })
: null
const after =
typeof paging?.next?.after === 'string'
? paging.next.after
: typeof record.after === 'string'
? record.after
: null
const data = ('results' in record ? record.results : parsed) as T
return {
data,
pageInfo: {
hasNextPage: Boolean(after),
after,
},
status: response.status,
}
}
export { isMutatingMethod, inferOperationFromPath }