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'),
})
}