Skip to content

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

Package listing

@kody/linkedin

src/request.ts

291 lines · 8.6 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'
import type {
	JsonRecord,
	LinkedInRequestInput,
	LinkedInRequestResult,
	QueryInput,
} from './types.ts'
import { requireRecord, requireStringField } from './types.ts'

export const LINKEDIN_API_ORIGIN = 'https://api.linkedin.com'
export const LINKEDIN_UPLOAD_ORIGIN = 'https://www.linkedin.com'
export const LINKEDIN_INTEGRATION = 'linkedin'
export const LINKEDIN_ACCESS_TOKEN_SECRET = 'linkedinAccessToken'
export const LINKEDIN_VERSION = '202602'
export const RESTLI_PROTOCOL_VERSION = '2.0.0'
export const LINKEDIN_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const LINKEDIN_CONNECT_URL = 'https://kody.codes/connect/oauth?provider=linkedin'
export const LINKEDIN_AUTHORIZE_URL = 'https://www.linkedin.com/oauth/v2/authorization'
export const LINKEDIN_TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken'
export const LINKEDIN_DASHBOARD_URL = 'https://www.linkedin.com/developers/apps'
export const LINKEDIN_SCOPES = ['openid', 'profile', 'email', 'w_member_social'] as const

export function byoConnectUrl(): string {
	const params = new URLSearchParams({
		provider: LINKEDIN_INTEGRATION,
		authorizeUrl: LINKEDIN_AUTHORIZE_URL,
		tokenUrl: LINKEDIN_TOKEN_URL,
		apiBaseUrl: LINKEDIN_API_ORIGIN,
		flow: 'confidential',
		tokenExchangeStyle: 'form',
		allowedHosts: 'api.linkedin.com,www.linkedin.com',
		dashboardUrl: LINKEDIN_DASHBOARD_URL,
		scopes: LINKEDIN_SCOPES.join(' '),
	})
	return LINKEDIN_CALLBACK_URL + '?' + params.toString()
}

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

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

let __authedFetch: typeof fetch | null = null

export async function getLinkedInAuthenticatedFetch(): Promise<typeof fetch> {
	if (!__authedFetch) {
		__authedFetch = await createAuthenticatedFetch(LINKEDIN_INTEGRATION)
	}
	return __authedFetch
}

/**
 * Compact authenticated LinkedIn API request helper.
 * Injects OAuth via createAuthenticatedFetch('linkedin') plus LinkedIn-Version /
 * X-Restli-Protocol-Version when restli is requested. Returns raw Response.
 */
export async function rawLinkedInRequest(
	path: string,
	options: {
		method?: string
		query?: QueryInput
		headers?: Record<string, string>
		body?: unknown
		restli?: boolean
		linkedinVersion?: string
	} = {},
): Promise<Response> {
	const url = buildLinkedInUrl(path, options.query)
	const headers = new Headers(options.headers)
	if (!headers.has('accept')) headers.set('accept', 'application/json')
	if (options.restli === true && !headers.has('x-restli-protocol-version')) {
		headers.set('x-restli-protocol-version', RESTLI_PROTOCOL_VERSION)
	}
	if (options.linkedinVersion && !headers.has('linkedin-version')) {
		headers.set('linkedin-version', options.linkedinVersion)
	}
	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) {
			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 getLinkedInAuthenticatedFetch()
	return authedFetch(url, init)
}

export async function uploadLinkedInImageBytes(
	uploadUrl: string,
	bytes: ArrayBuffer,
	contentType: string,
): Promise<LinkedInRequestResult> {
	const url = new URL(uploadUrl)
	if (url.origin !== LINKEDIN_UPLOAD_ORIGIN) {
		throw new Error('LinkedIn image upload URLs must use ' + LINKEDIN_UPLOAD_ORIGIN + '.')
	}
	const authedFetch = await getLinkedInAuthenticatedFetch()
	const response = await authedFetch(url, {
		method: 'PUT',
		headers: {
			'content-type': contentType,
		},
		body: bytes,
	})
	const parsedBody = await parseResponseBody(response)
	if (!response.ok) {
		throw new LinkedInApiError(
			'LinkedIn image upload failed: ' + response.status + ' ' + response.statusText,
			{
				status: response.status,
				statusText: response.statusText,
				details: parsedBody,
			},
		)
	}
	return {
		ok: response.ok,
		status: response.status,
		statusText: response.statusText,
		headers: selectResponseHeaders(response.headers),
		body: parsedBody,
	}
}

export async function linkedinRequestWithResponse(
	path: string,
	options: {
		method?: string
		query?: QueryInput
		headers?: Record<string, string>
		body?: unknown
		restli?: boolean
		linkedinVersion?: string
	} = {},
): Promise<LinkedInRequestResult> {
	const response = await rawLinkedInRequest(path, options)
	const parsedBody = await parseResponseBody(response)
	if (!response.ok) {
		throw new LinkedInApiError(
			'LinkedIn API request failed: ' + response.status + ' ' + response.statusText,
			{
				status: response.status,
				statusText: response.statusText,
				details: parsedBody,
			},
		)
	}
	return {
		ok: response.ok,
		status: response.status,
		statusText: response.statusText,
		headers: selectResponseHeaders(response.headers),
		body: parsedBody,
	}
}

export async function linkedinRequest<T>(
	path: string,
	options: {
		method?: string
		query?: QueryInput
		headers?: Record<string, string>
		body?: unknown
		restli?: boolean
		linkedinVersion?: string
	} = {},
): Promise<T> {
	const result = await linkedinRequestWithResponse(path, options)
	return result.body as T
}

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

	if (input.dryRun) {
		return {
			dryRun: true,
			method,
			url,
			body,
			headers: redactAuthHeader(input.headers),
		}
	}

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

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

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

	if (url.origin !== LINKEDIN_API_ORIGIN) {
		throw new Error('LinkedIn request URLs must use https://api.linkedin.com.')
	}

	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.append(key, String(item))
			}
		}
	}

	return url.toString()
}

export function linkedInRestHeadersPreview(): Record<string, string> {
	return {
		'linkedin-version': LINKEDIN_VERSION,
		'x-restli-protocol-version': RESTLI_PROTOCOL_VERSION,
	}
}

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 selectResponseHeaders(headers: Headers): Record<string, string> {
	const selected = ['x-restli-id', 'location', 'content-type', 'linkedin-version']
	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 linkedin OAuth integration]',
	}
}

/**
 * Call a LinkedIn API endpoint on `api.linkedin.com` using the saved OAuth token.
 * @param params.path - Relative API path such as `/v2/userinfo`.
 * @returns Parsed JSON body and response metadata.
 * @example
 * import request from 'kody:@kody/linkedin/request'
 * const result = await request({ path: '/v2/userinfo' })
 * // => { status: 200, data: { sub: '...', ... } }
 */
export default async function requestEntrypoint(
	params: Partial<LinkedInRequestInput> & Record<string, unknown> = {},
) {
	const input = requireRecord(params, 'request')
	requireStringField(input, 'path', 'request')
	return request(input as LinkedInRequestInput)
}