Skip to content
← Public packages

@kentcdodds/x

X API v2 helpers for tweets, search, legacy DMs, and encrypted X Chat via a Fly XDK sidecar.

src/openapi-client.ts

329 lines · 11.9 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'

/**
 * Scaffolded X OpenAPI client (dependency-free ESM).
 *
 * Source: openapi_client_scaffold against
 * https://api.twitter.com/2/openapi.json (X API v2 ~815KB; under the 4MB fetch cap).
 * Auth default: bearerSecret xBearerToken
 *
 * Unscoped {{secret:xBearerToken}} resolves for this user-scoped secret.
 * OAuth user-context calls use rawXRequest with authMode: 'oauth' and
 * createAuthenticatedFetch(integrationName) 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 = {
	fetchImpl?: typeof fetch
}

const API_BASE_URL = 'https://api.x.com'
const BEARER_AUTH_HEADER = 'Bearer {{secret:xBearerToken}}'

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 || value === '') continue
		if (Array.isArray(value)) {
			for (const item of value) {
				if (item === undefined || item === null || item === '') 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)
}

async function resolveFetch(fetchImpl?: typeof fetch): Promise<typeof fetch> {
	return fetchImpl ?? fetch
}

function bearerAuthHeaders(): Record<string, string> {
	return { Authorization: BEARER_AUTH_HEADER }
}

function oauthAuthHeaders(
	accessToken?: string,
	accessTokenSecretName = 'xAccessToken',
): Record<string, string> {
	return {
		Authorization: accessToken
			? `Bearer ${accessToken}`
			: `Bearer {{secret:${accessTokenSecretName}}}`,
	}
}

async function scaffoldFetch(
	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, bearerAuthHeaders())
	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 /2/users/me — Get my User (app-only bearer; prefer oauth via rawXRequest for user context). */
export async function getusersme(input: ScaffoldInput = {}, options: ScaffoldOptions = {}): Promise<Response> {
	return scaffoldFetch('GET', '/2/users/me', input, options)
}

/** GET /2/users/by/username/{username} — Get User by username */
export async function getusersbyusername(
	input: ScaffoldInput = {},
	options: ScaffoldOptions = {},
): Promise<Response> {
	const params = input.params ?? {}
	if (params.username === undefined || params.username === null) {
		throw new Error('Missing required path parameter: username')
	}
	return scaffoldFetch('GET', '/2/users/by/username/{username}', input, options)
}

/** GET /2/tweets/search/recent — Search recent Posts */
export async function searchpostsrecent(
	input: ScaffoldInput = {},
	options: ScaffoldOptions = {},
): Promise<Response> {
	return scaffoldFetch('GET', '/2/tweets/search/recent', input, options)
}

/** GET /2/users/{id}/tweets — Get Posts */
export async function getusersposts(input: ScaffoldInput = {}, options: ScaffoldOptions = {}): Promise<Response> {
	const params = input.params ?? {}
	if (params.id === undefined || params.id === null) {
		throw new Error('Missing required path parameter: id')
	}
	return scaffoldFetch('GET', '/2/users/{id}/tweets', input, options)
}

/** GET /2/tweets/{id} — Get Post by ID */
export async function getpostsbyid(input: ScaffoldInput = {}, options: ScaffoldOptions = {}): Promise<Response> {
	const params = input.params ?? {}
	if (params.id === undefined || params.id === null) {
		throw new Error('Missing required path parameter: id')
	}
	return scaffoldFetch('GET', '/2/tweets/{id}', input, options)
}

/** POST /2/tweets — Create or Edit Post */
export async function createposts(input: ScaffoldInput = {}, options: ScaffoldOptions = {}): Promise<Response> {
	return scaffoldFetch('POST', '/2/tweets', input, options)
}

/** DELETE /2/tweets/{id} — Delete Post */
export async function deleteposts(input: ScaffoldInput = {}, options: ScaffoldOptions = {}): Promise<Response> {
	const params = input.params ?? {}
	if (params.id === undefined || params.id === null) {
		throw new Error('Missing required path parameter: id')
	}
	return scaffoldFetch('DELETE', '/2/tweets/{id}', input, options)
}

/** POST /2/users/{id}/likes — Like Post */
export async function likepost(input: ScaffoldInput = {}, options: ScaffoldOptions = {}): Promise<Response> {
	const params = input.params ?? {}
	if (params.id === undefined || params.id === null) {
		throw new Error('Missing required path parameter: id')
	}
	return scaffoldFetch('POST', '/2/users/{id}/likes', input, options)
}

/** DELETE /2/users/{id}/likes/{tweet_id} — Unlike Post */
export async function unlikepost(input: ScaffoldInput = {}, options: ScaffoldOptions = {}): Promise<Response> {
	const params = input.params ?? {}
	if (params.id === undefined || params.id === null) {
		throw new Error('Missing required path parameter: id')
	}
	if (params.tweet_id === undefined || params.tweet_id === null) {
		throw new Error('Missing required path parameter: tweet_id')
	}
	return scaffoldFetch('DELETE', '/2/users/{id}/likes/{tweet_id}', input, options)
}

/** POST /2/users/{id}/retweets — Repost Post */
export async function repostpost(input: ScaffoldInput = {}, options: ScaffoldOptions = {}): Promise<Response> {
	const params = input.params ?? {}
	if (params.id === undefined || params.id === null) {
		throw new Error('Missing required path parameter: id')
	}
	return scaffoldFetch('POST', '/2/users/{id}/retweets', input, options)
}

/** DELETE /2/users/{id}/retweets/{source_tweet_id} — Unrepost Post */
export async function unrepostpost(input: ScaffoldInput = {}, options: ScaffoldOptions = {}): Promise<Response> {
	const params = input.params ?? {}
	if (params.id === undefined || params.id === null) {
		throw new Error('Missing required path parameter: id')
	}
	if (params.source_tweet_id === undefined || params.source_tweet_id === null) {
		throw new Error('Missing required path parameter: source_tweet_id')
	}
	return scaffoldFetch('DELETE', '/2/users/{id}/retweets/{source_tweet_id}', input, options)
}

/** POST /2/users/{id}/bookmarks — Create Bookmark */
export async function createusersbookmark(
	input: ScaffoldInput = {},
	options: ScaffoldOptions = {},
): Promise<Response> {
	const params = input.params ?? {}
	if (params.id === undefined || params.id === null) {
		throw new Error('Missing required path parameter: id')
	}
	return scaffoldFetch('POST', '/2/users/{id}/bookmarks', input, options)
}

/** DELETE /2/users/{id}/bookmarks/{tweet_id} — Delete Bookmark */
export async function deleteusersbookmark(
	input: ScaffoldInput = {},
	options: ScaffoldOptions = {},
): Promise<Response> {
	const params = input.params ?? {}
	if (params.id === undefined || params.id === null) {
		throw new Error('Missing required path parameter: id')
	}
	if (params.tweet_id === undefined || params.tweet_id === null) {
		throw new Error('Missing required path parameter: tweet_id')
	}
	return scaffoldFetch('DELETE', '/2/users/{id}/bookmarks/{tweet_id}', input, options)
}

/** POST /2/users/{id}/following — Follow User */
export async function followuser(input: ScaffoldInput = {}, options: ScaffoldOptions = {}): Promise<Response> {
	const params = input.params ?? {}
	if (params.id === undefined || params.id === null) {
		throw new Error('Missing required path parameter: id')
	}
	return scaffoldFetch('POST', '/2/users/{id}/following', input, options)
}

/** DELETE /2/users/{source_user_id}/following/{target_user_id} — Unfollow User */
export async function unfollowuser(input: ScaffoldInput = {}, options: ScaffoldOptions = {}): Promise<Response> {
	const params = input.params ?? {}
	if (params.source_user_id === undefined || params.source_user_id === null) {
		throw new Error('Missing required path parameter: source_user_id')
	}
	if (params.target_user_id === undefined || params.target_user_id === null) {
		throw new Error('Missing required path parameter: target_user_id')
	}
	return scaffoldFetch('DELETE', '/2/users/{source_user_id}/following/{target_user_id}', input, options)
}

export type XAuthMode = 'bearer' | 'oauth'

/**
 * Escape-hatch / OAuth-capable fetch for paths relative to https://api.x.com/2.
 * Use authMode: 'oauth' for user-context writes and /users/me.
 */
export async function rawXRequest(
	path: string,
	options: {
		method?: string
		query?: QueryInput
		body?: unknown
		headers?: Record<string, string>
		authMode?: XAuthMode
		accessToken?: string
		/** OAuth access-token secret name for multi-account integrations. Defaults to xAccessToken. */
		accessTokenSecretName?: string
		/** Saved OAuth integration name. OAuth calls use createAuthenticatedFetch(name). */
		integrationName?: string
		fetchImpl?: typeof fetch
	} = {},
): Promise<Response> {
	const method = (options.method || (options.body !== undefined ? 'POST' : 'GET')).toUpperCase()
	const authMode = options.authMode || (method === 'GET' ? 'bearer' : 'oauth')
	const normalized = path.replace(/^\/+/, '').replace(/^2\//, '')
	const url = appendQuery(`${API_BASE_URL}/2/${normalized}`, options.query)
	let body: string | undefined
	if (options.body !== undefined && options.body !== null) {
		body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body)
	}

	if (authMode === 'oauth' && !options.accessToken) {
		const integrationName = options.integrationName
		if (!integrationName) {
			throw new Error(
				'OAuth X requests need integrationName (or an explicit accessToken). Use createAuthenticatedFetch from kody:runtime.',
			)
		}
		const headers: Record<string, string> = {
			accept: 'application/json',
			...(options.headers ?? {}),
		}
		if (body !== undefined && !hasHeader(headers, 'content-type')) {
			headers['content-type'] = 'application/json'
		}
		const authenticatedFetch = await Promise.resolve(createAuthenticatedFetch(integrationName))
		return authenticatedFetch(url, { method, headers, body })
	}

	const auth =
		authMode === 'oauth'
			? oauthAuthHeaders(options.accessToken, options.accessTokenSecretName)
			: bearerAuthHeaders()
	const headers = mergeHeaders(
		{
			accept: 'application/json',
			...(options.headers ?? {}),
		},
		auth,
	)
	if (body !== undefined && !hasHeader(headers, 'content-type')) headers['content-type'] = 'application/json'
	const fetchImpl = await resolveFetch(options.fetchImpl)
	return fetchImpl(url, { method, headers, body })
}