import { planetscaleFetch, resolvePlanetscaleAuth, type ResolvedPlanetscaleAuth } from './auth.ts'
import type { JsonRecord, PlanetscaleAuthInput, PlanetscalePageInfo } from './types.ts'
import { requireString } from './types.ts'
import {
PLANETSCALE_API_BASE_URL,
PLANETSCALE_API_HOST,
inferOperationFromPath,
isMutatingMethod,
nextStepForAuthFailure,
type PlanetscaleOperation,
} from './setup.ts'
export class PlanetscaleApiError extends Error {
readonly status: number
readonly operation: PlanetscaleOperation
readonly details: unknown
constructor(
message: string,
options: {
status: number
operation: PlanetscaleOperation
details: unknown
},
) {
super(message)
this.name = 'PlanetscaleApiError'
this.status = options.status
this.operation = options.operation
this.details = options.details
}
}
export type PlanetscaleRequestInput = PlanetscaleAuthInput & {
path: string
method?: string
query?: JsonRecord
body?: JsonRecord
operation?: PlanetscaleOperation
}
export type PlanetscaleResponse<T> = {
data: T
pageInfo: PlanetscalePageInfo
status: number
}
export function normalizePlanetscalePath(path: string): string {
const trimmed = requireString(path, 'path')
if (trimmed.startsWith('https://')) {
const url = new URL(trimmed)
if (url.host !== PLANETSCALE_API_HOST) {
throw new Error(`PlanetScale requests must use host ${PLANETSCALE_API_HOST}. Got ${url.host}.`)
}
return `${url.pathname}${url.search}`
}
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
}
export function planetscaleUrl(path: string, query?: JsonRecord): string {
const normalized = normalizePlanetscalePath(path)
const url = new URL(normalized, `${PLANETSCALE_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' || typeof value === 'string') {
url.searchParams.set(key, String(value))
continue
}
throw new Error(`query.${key} must be a string, number, or boolean.`)
}
}
return url.toString()
}
function planetscaleMessage(body: unknown, fallback: string): string {
if (!body || typeof body !== 'object') return fallback
const record = body as { message?: unknown; error?: unknown; code?: unknown }
const message = typeof record.message === 'string' ? record.message : ''
const error = typeof record.error === 'string' ? record.error : ''
const code = typeof record.code === 'string' ? record.code : ''
return [code, message || error].filter(Boolean).join(': ') || fallback
}
function pageInfoFromBody(body: unknown): PlanetscalePageInfo {
if (!body || typeof body !== 'object') {
return { currentPage: null, nextPage: null, prevPage: null }
}
const record = body as JsonRecord
return {
currentPage: typeof record.current_page === 'number' ? record.current_page : null,
nextPage: typeof record.next_page === 'number' ? record.next_page : null,
prevPage: typeof record.prev_page === 'number' ? record.prev_page : null,
}
}
function dataFromBody(body: unknown): unknown {
if (!body || typeof body !== 'object') return body
const record = body as JsonRecord
if (record.type === 'list' && Array.isArray(record.data)) return record.data
return body
}
export async function planetscaleRequest<T = unknown>(
input: PlanetscaleRequestInput,
): Promise<PlanetscaleResponse<T>> {
const method = (input.method ?? 'GET').toUpperCase()
const path = requireString(input.path, 'path')
const operation = inferOperationFromPath(method, normalizePlanetscalePath(path), input.operation)
const auth = await resolvePlanetscaleAuth(input)
return planetscaleRequestWithAuth<T>(auth, {
method,
path,
query: input.query,
body: input.body,
operation,
})
}
export async function planetscaleRequestWithAuth<T = unknown>(
auth: ResolvedPlanetscaleAuth,
input: {
method: string
path: string
query?: JsonRecord
body?: JsonRecord
operation: PlanetscaleOperation
},
): Promise<PlanetscaleResponse<T>> {
const url = planetscaleUrl(input.path, input.query)
const headers = new Headers({
Accept: 'application/json',
'User-Agent': 'kody-planetscale/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 planetscaleFetch(auth, url, init)
const text = await response.text()
let parsed: unknown = text
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = text
}
const message = planetscaleMessage(parsed, response.statusText)
if (!response.ok) {
const authHint =
response.status === 401 || response.status === 403
? nextStepForAuthFailure(auth.secretName)
: ''
throw new PlanetscaleApiError(
[`PlanetScale ${input.operation} failed (${response.status}): ${message}.`, authHint]
.filter(Boolean)
.join(' '),
{
status: response.status,
operation: input.operation,
details: parsed,
},
)
}
return {
data: dataFromBody(parsed) as T,
pageInfo: pageInfoFromBody(parsed),
status: response.status,
}
}
export { isMutatingMethod, inferOperationFromPath }