Jaspers Hub

sj-jsprai / clinicaltrials

pluginReviewed★ 0↓ 1

ClinicalTrials.gov: Searches and retrieves clinical trial records from the public ClinicalTrials.gov API v2, no key required.

3.2 KB
1import 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
5export const HOST = 'clinicaltrials.gov'
6export const BASE = `https://${HOST}/api/v2`
7
8export 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. */
25export 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. */
49const 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. */
60export 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}