Skip to content
← Public packages

@kentcdodds/twitch

Monitor Twitch streams, send chat, and update channel settings.

src/openapi-client.ts

198 lines · 5.9 KB · TypeScript
/**
 * Scaffolded Twitch Helix OpenAPI client (dependency-free ESM).
 *
 * Source: openapi_client_scaffold against
 * https://raw.githubusercontent.com/DmitryScaletta/twitch-api-swagger/main/openapi.json
 * Slugs: get_users, get_streams, send_chat_message, get_chatters,
 *        modify_channel_information, search_categories
 * Auth: integration — createAuthenticatedFetch('twitch') for Authorization.
 * Helix also requires Client-Id; the package wrapper merges that via input.headers.
 *
 * Spec is community/unofficial; host approval stays in the account security UI.
 *
 * Note: scaffold emitted createAuthenticatedFetch(kody, "twitch"); runtime expects
 * createAuthenticatedFetch(providerName) — adapted to match Spotify/package convention.
 */

import { createAuthenticatedFetch } from 'kody:runtime'

export type QueryValue = string | number | boolean | null | undefined
export type QueryInput = Record<string, QueryValue | QueryValue[]>

export type ScaffoldInput = {
	params?: Record<string, unknown>
	query?: QueryInput
	headers?: Record<string, string>
	body?: unknown
}

export type ScaffoldOptions = {
	/** Bypass integration auth (tests). */
	fetchImpl?: typeof fetch
}

const API_BASE_URL = 'https://api.twitch.tv/helix'
const TWITCH_PROVIDER = 'twitch'

export function buildUrl(pathTemplate: string, params: Record<string, unknown> = {}): string {
	return API_BASE_URL + pathTemplate.replace(/\{([^}]+)\}/g, (_match, name: string) => {
		const value = params[name]
		if (value === undefined || value === null) {
			throw new Error(`Missing required path parameter: ${name}`)
		}
		return encodeURIComponent(String(value))
	})
}

export function appendQuery(url: string, query: QueryInput = {}): string {
	const search = new URLSearchParams()
	for (const [key, value] of Object.entries(query)) {
		if (value === undefined || value === null) continue
		if (Array.isArray(value)) {
			for (const item of value) {
				if (item === undefined || item === null) continue
				search.append(key, String(item))
			}
			continue
		}
		search.append(key, String(value))
	}
	const qs = search.toString()
	return qs ? `${url}?${qs}` : url
}

function mergeHeaders(
	userHeaders: Record<string, string> | undefined,
	authHeaders: Record<string, string>,
): Record<string, string> {
	const merged: Record<string, string> = { ...(userHeaders ?? {}) }
	for (const [key, value] of Object.entries(authHeaders)) {
		const lower = key.toLowerCase()
		for (const existing of Object.keys(merged)) {
			if (existing.toLowerCase() === lower) delete merged[existing]
		}
		merged[key] = value
	}
	return merged
}

function hasHeader(headers: Record<string, string>, name: string): boolean {
	const lower = name.toLowerCase()
	return Object.keys(headers).some((key) => key.toLowerCase() === lower)
}

let __authedFetch: typeof fetch | null = null

async function resolveFetch(fetchImpl?: typeof fetch): Promise<typeof fetch> {
	if (fetchImpl) return fetchImpl
	if (!__authedFetch) {
		__authedFetch = await createAuthenticatedFetch(TWITCH_PROVIDER)
	}
	return __authedFetch
}

/** Integration auth supplies Authorization; Client-Id is merged by package callers. */
function authHeaders(): Record<string, string> {
	return {}
}

async function call(
	method: string,
	pathTemplate: string,
	input: ScaffoldInput = {},
	options: ScaffoldOptions = {},
): Promise<Response> {
	const params = input.params ?? {}
	const url = appendQuery(buildUrl(pathTemplate, params), input.query)
	const headers = mergeHeaders(input.headers, authHeaders())
	let body: string | undefined
	if (input.body !== undefined) {
		body = JSON.stringify(input.body)
		if (!hasHeader(headers, 'content-type')) headers['content-type'] = 'application/json'
	}
	const fetchImpl = await resolveFetch(options.fetchImpl)
	return fetchImpl(url, { method, headers, body })
}

/** GET /users — get_users */
export async function getUsers(
	input: ScaffoldInput = {},
	options: ScaffoldOptions = {},
): Promise<Response> {
	return call('GET', '/users', input, options)
}

/** GET /streams — get_streams */
export async function getStreams(
	input: ScaffoldInput = {},
	options: ScaffoldOptions = {},
): Promise<Response> {
	return call('GET', '/streams', input, options)
}

/** POST /chat/messages — send_chat_message */
export async function sendChatMessage(
	input: ScaffoldInput = {},
	options: ScaffoldOptions = {},
): Promise<Response> {
	return call('POST', '/chat/messages', input, options)
}

/** GET /chat/chatters — get_chatters */
export async function getChatters(
	input: ScaffoldInput = {},
	options: ScaffoldOptions = {},
): Promise<Response> {
	return call('GET', '/chat/chatters', input, options)
}

/** PATCH /channels — modify_channel_information */
export async function modifyChannelInformation(
	input: ScaffoldInput = {},
	options: ScaffoldOptions = {},
): Promise<Response> {
	return call('PATCH', '/channels', input, options)
}

/** GET /search/categories — search_categories */
export async function searchCategories(
	input: ScaffoldInput = {},
	options: ScaffoldOptions = {},
): Promise<Response> {
	return call('GET', '/search/categories', input, options)
}

/**
 * Escape hatch for Helix paths not in the curated OpenAPI selection.
 * Path is relative to https://api.twitch.tv/helix (leading slash required).
 */
export async function rawHelixRequest(
	path: string,
	input: {
		method?: string
		query?: QueryInput
		headers?: Record<string, string>
		body?: unknown
		fetchImpl?: typeof fetch
	} = {},
): Promise<Response> {
	if (!path.startsWith('/')) {
		throw new Error('Helix path must start with "/" (relative to api.twitch.tv/helix).')
	}
	if (path.startsWith('http://') || path.startsWith('https://')) {
		throw new Error('Pass a Helix-relative path, not an absolute URL.')
	}
	const method = (input.method ?? (input.body === undefined ? 'GET' : 'POST')).toUpperCase()
	return call(
		method,
		path,
		{
			query: input.query,
			headers: input.headers,
			body: input.body,
		},
		{ fetchImpl: input.fetchImpl },
	)
}

export { API_BASE_URL, mergeHeaders, TWITCH_PROVIDER }