Skip to content

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

Package listing

@kody/producthunt

src/request.ts

138 lines · 4.2 KB · TypeScript
import { productHuntFetch, resolveProductHuntAuth } from './auth.ts'
import {
	PRODUCTHUNT_GRAPHQL_URL,
	inferOperation,
	isReadOnlyQuery,
	scopeForOperation,
} from './setup.ts'
import type {
	JsonRecord,
	ProductHuntGraphQLError,
	ProductHuntRequestInput,
	ProductHuntRequestResult,
} from './types.ts'
import {
	optionalBoolean,
	optionalString,
	requireRecord,
	requireString,
} from './types.ts'
import { parseAuthInput } from './auth.ts'

export class ProductHuntRequestError extends Error {
	result: ProductHuntRequestResult
	constructor(result: ProductHuntRequestResult) {
		const graphql = result.errors?.map((error) => error.message).filter(Boolean)
		super(
			graphql?.length
				? `Product Hunt GraphQL failed with ${result.status}: ${graphql.join('; ')}`
				: `Product Hunt request failed with ${result.status} ${result.statusText}`,
		)
		this.name = 'ProductHuntRequestError'
		this.result = result
	}
}

function compactVariables(variables: JsonRecord | undefined): JsonRecord {
	const output: JsonRecord = {}
	for (const [key, value] of Object.entries(variables ?? {})) {
		if (value !== undefined) output[key] = value
	}
	return output
}

/**
 * Authenticated Product Hunt GraphQL helper.
 * Query documents are read-only. Mutation documents need `confirm: true`
 * unless `dryRun: true`.
 */
export async function request<T = unknown>(
	options: ProductHuntRequestInput,
): Promise<ProductHuntRequestResult<T> | { dryRun: true; url: string; operation: string; query: string; variables: JsonRecord; readOnly: boolean }> {
	const query = requireString(options.query, 'query')
	const operation = inferOperation(query)
	const variables = compactVariables(options.variables)
	const readOnly = isReadOnlyQuery(query)

	if (options.dryRun) {
		return {
			dryRun: true,
			url: PRODUCTHUNT_GRAPHQL_URL,
			operation,
			query,
			variables,
			readOnly,
		}
	}

	if (!readOnly && options.confirm !== true) {
		throw new Error(
			`This Product Hunt GraphQL request is a ${operation} and may mutate data. Pass confirm: true only after explicit user approval, or use dryRun: true. Public comment writes are not in the API; mutations are goals and follow/unfollow (${scopeForOperation(operation)} scope).`,
		)
	}

	const auth = await resolveProductHuntAuth(options)
	const response = await productHuntFetch(auth, {
		method: 'POST',
		headers: {
			Accept: 'application/json',
			'Content-Type': 'application/json',
		},
		body: JSON.stringify({
			query,
			...(Object.keys(variables).length ? { variables } : {}),
			...(options.operationName ? { operationName: options.operationName } : {}),
		}),
	})

	const text = await response.text()
	let payload: { data?: T | null; errors?: ProductHuntGraphQLError[] } | null = null
	if (text) {
		try {
			payload = JSON.parse(text) as { data?: T | null; errors?: ProductHuntGraphQLError[] }
		} catch {
			payload = null
		}
	}

	const errors = payload?.errors?.length ? payload.errors : null
	const result: ProductHuntRequestResult<T> = {
		ok: response.ok && !errors,
		status: response.status,
		statusText: response.statusText,
		data: payload?.data ?? null,
		errors,
	}

	if (!result.ok && options.throwOnError !== false) {
		throw new ProductHuntRequestError(result)
	}
	return result
}

/**
 * Call the Product Hunt GraphQL API using saved OAuth or a developer token.
 * Mutations require `confirm: true`; use `dryRun: true` to preview.
 * @example
 * import request from 'kody:@kody/producthunt/request'
 * const result = await request({
 *   query: 'query { posts(featured: true, first: 1) { edges { node { slug } } } }',
 * })
 */
export default async function requestEntrypoint(
	params: Partial<ProductHuntRequestInput> & Record<string, unknown> = {},
) {
	const input = requireRecord(params, 'request')
	return request({
		...parseAuthInput(input),
		query: requireString(input.query, 'query'),
		variables:
			input.variables && typeof input.variables === 'object' && !Array.isArray(input.variables)
				? (input.variables as JsonRecord)
				: undefined,
		operationName: optionalString(input.operationName, 'operationName'),
		throwOnError: optionalBoolean(input.throwOnError, 'throwOnError'),
		confirm: optionalBoolean(input.confirm, 'confirm'),
		dryRun: optionalBoolean(input.dryRun, 'dryRun'),
	})
}