import type { BackendContext } from '@jaspers-ai/sdk' // The ClinicalTrials.gov API v2 client: builds a search query and fetches JSON with a time limit. export const HOST = 'clinicaltrials.gov' export const BASE = `https://${HOST}/api/v2` export interface SearchArgs { condition?: string intervention?: string sponsor?: string term?: string phases?: string[] statuses?: string[] startFrom?: string startTo?: string primaryCompletionFrom?: string primaryCompletionTo?: string sort?: string pageSize: number pageToken?: string } /** The query string for /studies from the search arguments. */ export function searchQuery(args: SearchArgs): URLSearchParams { const query = new URLSearchParams({ format: 'json', countTotal: 'true', pageSize: String(args.pageSize) }) query.set('fields', SEARCH_FIELDS.join(',')) if (args.condition) query.set('query.cond', args.condition) if (args.intervention) query.set('query.intr', args.intervention) if (args.sponsor) query.set('query.spons', args.sponsor) if (args.term) query.set('query.term', args.term) if (args.statuses?.length) query.set('filter.overallStatus', args.statuses.join(',')) const advanced: string[] = [] if (args.phases?.length) advanced.push(`AREA[Phase](${args.phases.join(' OR ')})`) if (args.startFrom || args.startTo) advanced.push(`AREA[StartDate]RANGE[${args.startFrom ?? 'MIN'}, ${args.startTo ?? 'MAX'}]`) if (args.primaryCompletionFrom || args.primaryCompletionTo) advanced.push( `AREA[PrimaryCompletionDate]RANGE[${args.primaryCompletionFrom ?? 'MIN'}, ${args.primaryCompletionTo ?? 'MAX'}]`, ) if (advanced.length) query.set('filter.advanced', advanced.join(' AND ')) // The API sorts a date field newest first when no direction is given; a bare field here means ascending. if (args.sort) query.set('sort', args.sort.includes(':') || args.sort.startsWith('@') ? args.sort : `${args.sort}:asc`) if (args.pageToken) query.set('pageToken', args.pageToken) return query } /** The modules a search row reads, so a page of results stays small. */ const SEARCH_FIELDS = [ 'protocolSection.identificationModule', 'protocolSection.statusModule', 'protocolSection.sponsorCollaboratorsModule', 'protocolSection.conditionsModule', 'protocolSection.designModule', 'protocolSection.armsInterventionsModule', 'hasResults', ] /** GET a path under the API, aborted after a few seconds; throws with the status and the body's first line. */ export async function getJson(ctx: BackendContext, path: string, query?: URLSearchParams): Promise { const url = `${BASE}${path}${query ? `?${query}` : ''}` const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 4500) try { const response = await ctx.fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } }) if (!response.ok) { const body = (await response.text()).split('\n')[0] ?? '' throw new Error(`ClinicalTrials.gov answered ${response.status}: ${body.slice(0, 200)}`) } return await response.json() } catch (error) { if (controller.signal.aborted) throw new Error('ClinicalTrials.gov did not answer within 4.5 seconds; try a smaller pageSize.') throw error } finally { clearTimeout(timer) } }