Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/origin

src/http.ts

118 lines · 3.4 KB · TypeScript
import { type OriginQuery, type OriginRequestResult } from './types.ts'

const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])

export function isMutatingMethod(method: string): boolean {
	return !SAFE_HTTP_METHODS.has(method.toUpperCase())
}

export function assertMutationGuard(
	mutating: boolean,
	dryRun: boolean | undefined,
	confirm: boolean | undefined,
	action: string,
) {
	if (!mutating) return
	if (dryRun === true) return
	if (confirm === true) return
	throw new Error(
		`Origin mutation "${action}" requires dryRun: true (preview, no Origin write) or confirm: true (live write).`,
	)
}

export async function originFetch(input: {
	path: string
	method?: string
	query?: OriginQuery
	body?: unknown
	bearerToken: string
}): Promise<OriginRequestResult> {
	const url = new URL(resolveOriginPath(input.path), 'https://api.cursor.com')
	if (input.query) {
		for (const [key, value] of Object.entries(input.query)) {
			if (value === undefined || value === null) continue
			url.searchParams.set(key, String(value))
		}
	}
	const method = (input.method ?? 'GET').toUpperCase()
	const headers: Record<string, string> = {
		Authorization: `Bearer ${input.bearerToken}`,
		Accept: 'application/json',
	}
	let body: string | undefined
	if (input.body !== undefined && method !== 'GET' && method !== 'HEAD') {
		headers['Content-Type'] = 'application/json'
		body = JSON.stringify(input.body)
	}
	const response = await fetch(url.toString(), { method, headers, body })
	const text = await response.text()
	let parsed: unknown = text
	if (text) {
		try {
			parsed = JSON.parse(text)
		} catch {
			parsed = text
		}
	} else {
		parsed = null
	}
	return {
		ok: response.ok,
		status: response.status,
		body: parsed,
		rateLimit: {
			limit: response.headers.get('x-ratelimit-limit'),
			remaining: response.headers.get('x-ratelimit-remaining'),
			used: response.headers.get('x-ratelimit-used'),
			reset: response.headers.get('x-ratelimit-reset'),
		},
	}
}

export function resolveOriginPath(path: string): string {
	const trimmed = path.trim()
	if (!trimmed) {
		throw new Error('Origin request path is required.')
	}
	if (/^https?:\/\//i.test(trimmed)) {
		throw new Error('Pass an Origin API path, not an absolute URL.')
	}
	if (trimmed.startsWith('/v1/origin/')) return trimmed
	const suffix = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
	return `/v1/origin${suffix}`
}

export function isRecord(value: unknown): value is Record<string, unknown> {
	return value !== null && typeof value === 'object' && !Array.isArray(value)
}

export function asRecord(value: unknown): Record<string, unknown> {
	return isRecord(value) ? value : {}
}

export function stringField(value: unknown): string | null {
	return typeof value === 'string' && value.length > 0 ? value : null
}

export function numberField(value: unknown): number | null {
	return typeof value === 'number' && Number.isFinite(value) ? value : null
}

export function booleanField(value: unknown): boolean | null {
	return typeof value === 'boolean' ? value : null
}

export function assertOk(status: number, body: unknown, action: string) {
	if (status >= 200 && status < 300) return
	const message =
		isRecord(body) && typeof body.message === 'string'
			? body.message
			: `HTTP ${String(status)}`
	throw new Error(`Failed to ${action}: ${message}`)
}

export function originErrorMessage(body: unknown, status: number): string {
	return isRecord(body) && typeof body.message === 'string'
		? body.message
		: `HTTP ${String(status)}`
}