Skip to content

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

Package listing

@kentcdodds/producthunt

src/request.ts

107 lines · 3.6 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'
import type {
	ProductHuntGraphQLError,
	ProductHuntRequestInput,
	ProductHuntRequestResult,
} from './types.ts'

export const PRODUCTHUNT_GRAPHQL_URL = 'https://api.producthunt.com/v2/api/graphql'
export const PRODUCTHUNT_INTEGRATION = 'producthunt'
export const PRODUCTHUNT_ACCESS_TOKEN_SECRET = 'producthuntAccessToken'
export const PRODUCTHUNT_CLIENT_SECRET_SECRET = 'producthuntClientSecret'
export const PRODUCTHUNT_AUTHORIZE_URL = 'https://api.producthunt.com/v2/oauth/authorize'
export const PRODUCTHUNT_TOKEN_URL = 'https://api.producthunt.com/v2/oauth/token'
export const PRODUCTHUNT_APPLICATIONS_URL = 'https://api.producthunt.com/v2/oauth/applications'

/** Error thrown when a Product Hunt GraphQL request fails. */
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
	}
}

/**
 * Authenticated `fetch` bound to the saved Product Hunt OAuth integration.
 */
export async function getProductHuntAuthenticatedFetch(
	integration = PRODUCTHUNT_INTEGRATION,
): Promise<typeof fetch> {
	return await createAuthenticatedFetch(integration)
}

/**
 * Compact authenticated Product Hunt GraphQL request helper.
 * Auth: `createAuthenticatedFetch('producthunt')` against the v2 GraphQL endpoint.
 */
export async function request<T = unknown>(
	options: ProductHuntRequestInput,
): Promise<ProductHuntRequestResult<T>> {
	const query = options.query?.trim()
	if (!query) throw new Error('query is required.')

	const authFetch = await getProductHuntAuthenticatedFetch(
		options.integration ?? PRODUCTHUNT_INTEGRATION,
	)
	const variables = Object.fromEntries(
		Object.entries(options.variables ?? {}).filter(([, value]) => value !== undefined),
	)
	const response = await authFetch(PRODUCTHUNT_GRAPHQL_URL, {
		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 the saved OAuth token.
 * @param params.query - GraphQL query or mutation document.
 * @returns Parsed `data` plus GraphQL `errors` when present.
 * @example
 * import request from 'kody:@kentcdodds/producthunt/request'
 * const result = await request({
 *   query: 'query { viewer { user { username } } }',
 * })
 * // => { ok: true, status: 200, data: { viewer: { user: { username: '...' } } }, errors: null }
 */
export default async function requestEntrypoint(params: ProductHuntRequestInput) {
	return await request(params)
}