ClinicalTrials.gov: Searches and retrieves clinical trial records from the public ClinicalTrials.gov API v2, no key required.
| 1 | import type { BackendContext } from '@jaspers-ai/sdk' |
| 2 | |
| 3 | // The ClinicalTrials.gov API v2 client: builds a search query and fetches JSON with a time limit. |
| 4 | |
| 5 | export const HOST = 'clinicaltrials.gov' |
| 6 | export const BASE = `https://${HOST}/api/v2` |
| 7 | |
| 8 | export interface SearchArgs { |
| 9 | condition?: string |
| 10 | intervention?: string |
| 11 | sponsor?: string |
| 12 | term?: string |
| 13 | phases?: string[] |
| 14 | statuses?: string[] |
| 15 | startFrom?: string |
| 16 | startTo?: string |
| 17 | primaryCompletionFrom?: string |
| 18 | primaryCompletionTo?: string |
| 19 | sort?: string |
| 20 | pageSize: number |
| 21 | pageToken?: string |
| 22 | } |
| 23 | |
| 24 | /** The query string for /studies from the search arguments. */ |
| 25 | export function searchQuery(args: SearchArgs): URLSearchParams { |
| 26 | const query = new URLSearchParams({ format: 'json', countTotal: 'true', pageSize: String(args.pageSize) }) |
| 27 | query.set('fields', SEARCH_FIELDS.join(',')) |
| 28 | if (args.condition) query.set('query.cond', args.condition) |
| 29 | if (args.intervention) query.set('query.intr', args.intervention) |
| 30 | if (args.sponsor) query.set('query.spons', args.sponsor) |
| 31 | if (args.term) query.set('query.term', args.term) |
| 32 | if (args.statuses?.length) query.set('filter.overallStatus', args.statuses.join(',')) |
| 33 | const advanced: string[] = [] |
| 34 | if (args.phases?.length) advanced.push(`AREA[Phase](${args.phases.join(' OR ')})`) |
| 35 | if (args.startFrom || args.startTo) |
| 36 | advanced.push(`AREA[StartDate]RANGE[${args.startFrom ?? 'MIN'}, ${args.startTo ?? 'MAX'}]`) |
| 37 | if (args.primaryCompletionFrom || args.primaryCompletionTo) |
| 38 | advanced.push( |
| 39 | `AREA[PrimaryCompletionDate]RANGE[${args.primaryCompletionFrom ?? 'MIN'}, ${args.primaryCompletionTo ?? 'MAX'}]`, |
| 40 | ) |
| 41 | if (advanced.length) query.set('filter.advanced', advanced.join(' AND ')) |
| 42 | // The API sorts a date field newest first when no direction is given; a bare field here means ascending. |
| 43 | if (args.sort) query.set('sort', args.sort.includes(':') || args.sort.startsWith('@') ? args.sort : `${args.sort}:asc`) |
| 44 | if (args.pageToken) query.set('pageToken', args.pageToken) |
| 45 | return query |
| 46 | } |
| 47 | |
| 48 | /** The modules a search row reads, so a page of results stays small. */ |
| 49 | const SEARCH_FIELDS = [ |
| 50 | 'protocolSection.identificationModule', |
| 51 | 'protocolSection.statusModule', |
| 52 | 'protocolSection.sponsorCollaboratorsModule', |
| 53 | 'protocolSection.conditionsModule', |
| 54 | 'protocolSection.designModule', |
| 55 | 'protocolSection.armsInterventionsModule', |
| 56 | 'hasResults', |
| 57 | ] |
| 58 | |
| 59 | /** GET a path under the API, aborted after a few seconds; throws with the status and the body's first line. */ |
| 60 | export async function getJson(ctx: BackendContext, path: string, query?: URLSearchParams): Promise<unknown> { |
| 61 | const url = `${BASE}${path}${query ? `?${query}` : ''}` |
| 62 | const controller = new AbortController() |
| 63 | const timer = setTimeout(() => controller.abort(), 4500) |
| 64 | try { |
| 65 | const response = await ctx.fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } }) |
| 66 | if (!response.ok) { |
| 67 | const body = (await response.text()).split('\n')[0] ?? '' |
| 68 | throw new Error(`ClinicalTrials.gov answered ${response.status}: ${body.slice(0, 200)}`) |
| 69 | } |
| 70 | return await response.json() |
| 71 | } catch (error) { |
| 72 | if (controller.signal.aborted) throw new Error('ClinicalTrials.gov did not answer within 4.5 seconds; try a smaller pageSize.') |
| 73 | throw error |
| 74 | } finally { |
| 75 | clearTimeout(timer) |
| 76 | } |
| 77 | } |