Skip to content

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

Package listing

@kody/reddit

src/request.ts

345 lines · 10.4 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'
import type {
	AccountInput,
	JsonRecord,
	QueryInput,
	RedditRequestInput,
	RedditRequestResult,
} from './types.ts'
import { requireRecord, requireStringField } from './types.ts'

export const REDDIT_API_ORIGIN = 'https://oauth.reddit.com'
export const REDDIT_WWW_ORIGIN = 'https://www.reddit.com'
export const REDDIT_INTEGRATION = 'reddit'
export const REDDIT_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const REDDIT_CONNECT_URL = 'https://kody.codes/connect/oauth?provider=reddit'
export const REDDIT_AUTHORIZE_URL = 'https://www.reddit.com/api/v1/authorize'
export const REDDIT_TOKEN_URL = 'https://www.reddit.com/api/v1/access_token'
export const REDDIT_DASHBOARD_URL = 'https://www.reddit.com/prefs/apps'
export const REDDIT_USER_AGENT = 'web:kody.reddit:1.0.0 (https://kody.codes/@kody/reddit)'
export const REDDIT_SCOPES = [
	'identity',
	'read',
	'history',
	'mysubreddits',
	'submit',
	'edit',
	'vote',
	'save',
] as const
export const REDDIT_SETUP_INSTRUCTIONS =
	'Create a Reddit web app at https://www.reddit.com/prefs/apps. App type must be "web app". Redirect URI must be exactly https://kody.codes/connect/oauth. Paste the client id and secret into this Kody form — never into chat. duration=permanent is already requested so Kody can refresh tokens.'

const authedFetchCache = new Map<string, typeof fetch>()

export function byoConnectUrl(): string {
	const params = new URLSearchParams({
		provider: REDDIT_INTEGRATION,
		authorizeUrl: REDDIT_AUTHORIZE_URL,
		tokenUrl: REDDIT_TOKEN_URL,
		apiBaseUrl: REDDIT_API_ORIGIN,
		flow: 'confidential',
		tokenExchangeStyle: 'basic-form',
		allowedHosts: 'oauth.reddit.com,www.reddit.com',
		dashboardUrl: REDDIT_DASHBOARD_URL,
		scopes: REDDIT_SCOPES.join(' '),
		extraAuthorizeParams: 'duration=permanent',
		providerSetupInstructions: REDDIT_SETUP_INSTRUCTIONS,
	})
	return REDDIT_CALLBACK_URL + '?' + params.toString()
}

export function resolveIntegration(input: AccountInput = {}): string {
	if (input.integration) {
		return requireName(input.integration, 'params.integration')
	}
	const account = input.account
	if (!account || account === 'default' || account === 'reddit') {
		return REDDIT_INTEGRATION
	}
	const name = requireName(account, 'params.account')
	if (name === 'reddit' || name.startsWith('reddit-')) {
		return name
	}
	return 'reddit-' + name
}

export class RedditApiError extends Error {
	readonly status: number
	readonly statusText: string
	readonly details: unknown
	readonly integration: string

	constructor(
		message: string,
		options: { status: number; statusText: string; details: unknown; integration: string },
	) {
		super(message)
		this.name = 'RedditApiError'
		this.status = options.status
		this.statusText = options.statusText
		this.details = options.details
		this.integration = options.integration
	}
}

export async function getRedditAuthenticatedFetch(integration: string): Promise<typeof fetch> {
	const cached = authedFetchCache.get(integration)
	if (cached) return cached
	const authedFetch = await createAuthenticatedFetch(integration)
	authedFetchCache.set(integration, authedFetch)
	return authedFetch
}

export function buildRedditUrl(path: string, query: QueryInput = {}): string {
	if (typeof path !== 'string' || path.length === 0) {
		throw new Error('Reddit request path is required.')
	}
	const url = path.startsWith('https://')
		? new URL(path)
		: new URL(REDDIT_API_ORIGIN + (path.startsWith('/') ? path : '/' + path))

	if (url.origin !== REDDIT_API_ORIGIN) {
		throw new Error('Reddit request URLs must use https://oauth.reddit.com.')
	}

	if (!url.searchParams.has('raw_json')) {
		url.searchParams.set('raw_json', '1')
	}

	for (const [key, value] of Object.entries(query)) {
		const values = Array.isArray(value) ? value : [value]
		for (const item of values) {
			if (item !== null && item !== undefined && item !== '') {
				url.searchParams.set(key, String(item))
			}
		}
	}

	return url.toString()
}

export async function rawRedditRequest(
	path: string,
	options: {
		method?: string
		query?: QueryInput
		headers?: Record<string, string>
		body?: unknown
		integration?: string
	} = {},
): Promise<Response> {
	const integration = options.integration ?? REDDIT_INTEGRATION
	const url = buildRedditUrl(path, options.query)
	const headers = new Headers(options.headers)
	if (!headers.has('accept')) headers.set('accept', 'application/json')
	if (!headers.has('user-agent')) headers.set('user-agent', REDDIT_USER_AGENT)
	const method = (options.method || 'GET').toUpperCase()
	const init: RequestInit = { method, headers }
	if (options.body !== undefined && options.body !== null) {
		if (typeof options.body === 'string') {
			init.body = options.body
		} else if (options.body instanceof URLSearchParams || options.body instanceof ArrayBuffer) {
			if (options.body instanceof URLSearchParams && !headers.has('content-type')) {
				headers.set('content-type', 'application/x-www-form-urlencoded')
			}
			init.body = options.body
		} else {
			if (!headers.has('content-type')) headers.set('content-type', 'application/json')
			init.body = JSON.stringify(options.body)
		}
	}
	const authedFetch = await getRedditAuthenticatedFetch(integration)
	return authedFetch(url, init)
}

export async function redditRequestWithResponse(
	path: string,
	options: {
		method?: string
		query?: QueryInput
		headers?: Record<string, string>
		body?: unknown
		integration?: string
	} = {},
): Promise<RedditRequestResult> {
	const integration = options.integration ?? REDDIT_INTEGRATION
	const response = await rawRedditRequest(path, { ...options, integration })
	const parsedBody = await parseResponseBody(response)
	if (!response.ok) {
		throw new RedditApiError(
			enhanceAuthMessage(
				'Reddit API request failed: ' + response.status + ' ' + response.statusText,
				response.status,
			),
			{
				status: response.status,
				statusText: response.statusText,
				details: parsedBody,
				integration,
			},
		)
	}
	assertRedditPayload(parsedBody, integration)
	return {
		ok: response.ok,
		status: response.status,
		statusText: response.statusText,
		headers: selectResponseHeaders(response.headers),
		body: parsedBody,
		integration,
	}
}

export async function redditRequest<T>(
	path: string,
	options: {
		method?: string
		query?: QueryInput
		headers?: Record<string, string>
		body?: unknown
		integration?: string
	} = {},
): Promise<T> {
	const result = await redditRequestWithResponse(path, options)
	return result.body as T
}

/** Public request helper with dry-run / confirm guards for non-GET calls. */
export async function request(
	input: RedditRequestInput,
): Promise<RedditRequestResult | JsonRecord> {
	const method = (input.method || 'GET').toUpperCase()
	const integration = resolveIntegration(input)
	const url = buildRedditUrl(input.path, input.query)
	const body = input.body ?? null

	if (input.dryRun) {
		return {
			dryRun: true,
			method,
			url,
			body,
			integration,
			headers: {
				...redactAuthHeader(input.headers),
				'user-agent': REDDIT_USER_AGENT,
			},
			requiresConfirm: method !== 'GET' && method !== 'HEAD',
		}
	}

	if (method !== 'GET' && method !== 'HEAD' && input.confirm !== true) {
		throw new Error(
			'request performs a non-read Reddit API call. Pass params.confirm = true only after explicit user approval.',
		)
	}

	return redditRequestWithResponse(input.path, {
		method,
		query: input.query,
		headers: input.headers,
		body: input.body,
		integration,
	})
}

export async function parseResponseBody(response: Response): Promise<unknown> {
	const text = await response.text()
	if (!text) return null
	try {
		return JSON.parse(text)
	} catch {
		return text
	}
}

function assertRedditPayload(body: unknown, integration: string): void {
	const record = body && typeof body === 'object' && !Array.isArray(body) ? (body as JsonRecord) : null
	if (!record) return
	if (typeof record.error === 'number' || typeof record.error === 'string') {
		throw new RedditApiError(
			enhanceAuthMessage(
				'Reddit API returned error ' + String(record.error) + (record.message ? ': ' + String(record.message) : ''),
				typeof record.error === 'number' ? record.error : 400,
			),
			{
				status: typeof record.error === 'number' ? record.error : 400,
				statusText: stringOrEmpty(record.message),
				details: body,
				integration,
			},
		)
	}
	const json = record.json && typeof record.json === 'object' ? (record.json as JsonRecord) : null
	const errors = json && Array.isArray(json.errors) ? json.errors : null
	if (errors && errors.length > 0) {
		throw new RedditApiError('Reddit API returned errors: ' + JSON.stringify(errors), {
			status: 400,
			statusText: 'Reddit json.errors',
			details: body,
			integration,
		})
	}
}

function enhanceAuthMessage(message: string, status: number): string {
	if (status !== 401 && status !== 403) return message
	return (
		message +
		'. Confirm the reddit OAuth integration is connected and current, then reconnect at ' +
		REDDIT_CONNECT_URL +
		'.'
	)
}

function selectResponseHeaders(headers: Headers): Record<string, string> {
	const selected = [
		'content-type',
		'x-ratelimit-used',
		'x-ratelimit-remaining',
		'x-ratelimit-reset',
	]
	const output: Record<string, string> = {}
	for (const key of selected) {
		const value = headers.get(key)
		if (value) output[key] = value
	}
	return output
}

function redactAuthHeader(headers: Record<string, string> = {}): Record<string, string> {
	return {
		...headers,
		authorization: '[managed reddit OAuth integration]',
	}
}

function requireName(value: string, label: string): string {
	const name = value.trim()
	if (!name) {
		throw new Error(label + ' must be a non-empty string.')
	}
	return name
}

function stringOrEmpty(value: unknown): string {
	return typeof value === 'string' ? value : ''
}

/**
 * Call a Reddit OAuth API endpoint on `oauth.reddit.com` using the saved integration.
 * @param params.path - Relative API path such as `/api/v1/me` or `/r/programming/hot`.
 * @returns Parsed JSON body and response metadata, or a dry-run preview.
 * @example
 * import request from 'kody:@kody/reddit/request'
 * const result = await request({ path: '/r/programming/about' })
 * // => { status: 200, body: { data: { display_name: 'programming', ... } }, ... }
 */
export default async function requestEntrypoint(
	params: Partial<RedditRequestInput> & Record<string, unknown> = {},
) {
	const input = requireRecord(params, 'request')
	requireStringField(input, 'path', 'request')
	return request(input as RedditRequestInput)
}