@cyanheads/pubchem-mcp-server 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +6 -3
- package/changelog/0.6.x/0.6.4.md +23 -0
- package/changelog/0.6.x/0.6.5.md +22 -0
- package/dist/mcp-server/resources/definitions/assay.resource.js +3 -3
- package/dist/mcp-server/resources/definitions/assay.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/compound-bioactivity.resource.js +3 -3
- package/dist/mcp-server/resources/definitions/compound-bioactivity.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/compound-image.resource.js +3 -3
- package/dist/mcp-server/resources/definitions/compound-image.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/compound-safety.resource.js +3 -3
- package/dist/mcp-server/resources/definitions/compound-safety.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/compound-xrefs.resource.js +3 -3
- package/dist/mcp-server/resources/definitions/compound-xrefs.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/compound.resource.js +3 -3
- package/dist/mcp-server/resources/definitions/compound.resource.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-bioactivity.tool.js +3 -3
- package/dist/mcp-server/tools/definitions/get-bioactivity.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-3d-structure.tool.js +5 -5
- package/dist/mcp-server/tools/definitions/get-compound-3d-structure.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-details.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-details.tool.js +5 -5
- package/dist/mcp-server/tools/definitions/get-compound-details.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-image.tool.js +2 -2
- package/dist/mcp-server/tools/definitions/get-compound-image.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.js +2 -2
- package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-safety.tool.js +2 -2
- package/dist/mcp-server/tools/definitions/get-compound-safety.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-xrefs.tool.js +2 -2
- package/dist/mcp-server/tools/definitions/get-compound-xrefs.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-summary.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/get-summary.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +13 -2
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-assays.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/search-assays.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-compounds.tool.d.ts +13 -2
- package/dist/mcp-server/tools/definitions/search-compounds.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-compounds.tool.js +93 -42
- package/dist/mcp-server/tools/definitions/search-compounds.tool.js.map +1 -1
- package/dist/services/pubchem/pubchem-client.d.ts +40 -26
- package/dist/services/pubchem/pubchem-client.d.ts.map +1 -1
- package/dist/services/pubchem/pubchem-client.js +242 -106
- package/dist/services/pubchem/pubchem-client.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +4 -4
- package/server.json +3 -3
|
@@ -3,9 +3,35 @@
|
|
|
3
3
|
* Wraps both PUG REST and PUG View APIs behind a shared rate limiter.
|
|
4
4
|
* @module services/pubchem/pubchem-client
|
|
5
5
|
*/
|
|
6
|
-
import { JsonRpcErrorCode, McpError, notFound, serviceUnavailable, } from '@cyanheads/mcp-ts-core/errors';
|
|
7
|
-
import { httpErrorFromResponse } from '@cyanheads/mcp-ts-core/utils';
|
|
6
|
+
import { JsonRpcErrorCode, McpError, notFound, requestCancelled, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
|
|
7
|
+
import { httpErrorFromResponse, logger, requestContextService } from '@cyanheads/mcp-ts-core/utils';
|
|
8
8
|
const isNotFound = (error) => error instanceof McpError && error.code === JsonRpcErrorCode.NotFound;
|
|
9
|
+
const isBadRequest = (error) => error instanceof McpError && error.data?.status === 400;
|
|
10
|
+
/** The parsed upstream fault `fetchResponse` attaches to every HTTP failure. */
|
|
11
|
+
const faultOf = (error) => {
|
|
12
|
+
const fault = error instanceof McpError ? error.data?.fault : undefined;
|
|
13
|
+
return typeof fault === 'string' ? fault : undefined;
|
|
14
|
+
};
|
|
15
|
+
/** PubChem's answer when a fast search cannot run on the query itself: a malformed SMILES or
|
|
16
|
+
* formula, a SMILES with a "*" wildcard atom, a CID with no record. Every re-send of a
|
|
17
|
+
* faulting request has faulted again, and no valid query has drawn it — a slow search gets
|
|
18
|
+
* 504 `PUGREST.Timeout`, a search with no hits 404 `PUGREST.NotFound`. So `fetchResponse`
|
|
19
|
+
* spends no retry on it, and the search methods, which know the query form, turn it into a
|
|
20
|
+
* typed `search_query_rejected`. */
|
|
21
|
+
const SEARCH_REJECTED_FAULT = 'PUGREST.ServerError: Search status indicates failure';
|
|
22
|
+
/** Re-throws a rejected-query fault from a fast search as `search_query_rejected`, with a
|
|
23
|
+
* hint for the query form the caller sent. Every other failure passes through unchanged. */
|
|
24
|
+
function mapRejectedSearch(search, message, hint) {
|
|
25
|
+
return search.catch((error) => {
|
|
26
|
+
if (faultOf(error) !== SEARCH_REJECTED_FAULT)
|
|
27
|
+
throw error;
|
|
28
|
+
throw validationError(message, { reason: 'search_query_rejected', fault: SEARCH_REJECTED_FAULT, recovery: { hint } }, { cause: error });
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** CID 0 is PubChem's "no such structure" placeholder, never a compound: a SMILES lookup for a
|
|
32
|
+
* structure that parses but is not in the database answers 200 with `CID: [0]`. Dropped
|
|
33
|
+
* wherever a CID list is read, so no route can report it as a match. */
|
|
34
|
+
const realCids = (cids) => cids.filter((cid) => cid > 0);
|
|
9
35
|
/** Distinguishes the two outcomes PUG View hides behind a single HTTP 404.
|
|
10
36
|
*
|
|
11
37
|
* `heading=`-filtered PUG View requests answer both "this CID has no record" and "this CID's
|
|
@@ -17,12 +43,11 @@ const isNotFound = (error) => error instanceof McpError && error.code === JsonRp
|
|
|
17
43
|
* Defaults to false when the message is missing or unrecognized: claiming a compound has no
|
|
18
44
|
* data understates what is known, while wrongly claiming a CID does not exist sends the caller
|
|
19
45
|
* chasing a correct identifier. */
|
|
20
|
-
const isMissingRecord = (error) =>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
};
|
|
46
|
+
const isMissingRecord = (error) => isNotFound(error) && (faultOf(error)?.includes('No record found') ?? false);
|
|
47
|
+
/** What a caller's cancellation surfaces as. `RequestCancelled` is never retried and the
|
|
48
|
+
* framework logs it at `info`; raising it here keeps a withdrawn call from reading as the 30 s
|
|
49
|
+
* timeout, and from matching the not-found checks that turn a 404 into "no data". */
|
|
50
|
+
const cancellation = (reason) => requestCancelled('PubChem request cancelled by the caller.', undefined, { cause: reason });
|
|
26
51
|
// ── Rate Limiter ─────────────────────────────────────────────────────
|
|
27
52
|
/** Sliding-window rate limiter. Queues requests exceeding maxPerSecond. */
|
|
28
53
|
class RateLimiter {
|
|
@@ -33,9 +58,23 @@ class RateLimiter {
|
|
|
33
58
|
constructor(maxPerSecond) {
|
|
34
59
|
this.max = maxPerSecond;
|
|
35
60
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
61
|
+
/** Waits for a slot. A caller that cancels while queued leaves the queue without taking one. */
|
|
62
|
+
acquire(signal) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
if (signal?.aborted) {
|
|
65
|
+
reject(cancellation(signal.reason));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const grant = () => {
|
|
69
|
+
signal?.removeEventListener('abort', withdraw);
|
|
70
|
+
resolve();
|
|
71
|
+
};
|
|
72
|
+
const withdraw = () => {
|
|
73
|
+
this.queue.splice(this.queue.indexOf(grant), 1);
|
|
74
|
+
reject(cancellation(signal?.reason));
|
|
75
|
+
};
|
|
76
|
+
signal?.addEventListener('abort', withdraw, { once: true });
|
|
77
|
+
this.queue.push(grant);
|
|
39
78
|
if (!this.draining)
|
|
40
79
|
void this.drain();
|
|
41
80
|
});
|
|
@@ -61,8 +100,45 @@ class RateLimiter {
|
|
|
61
100
|
this.draining = false;
|
|
62
101
|
}
|
|
63
102
|
}
|
|
64
|
-
|
|
65
|
-
const sleep = (ms) => new Promise((
|
|
103
|
+
/** Waits `ms`, or rejects with the cancellation as soon as `signal` aborts. */
|
|
104
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
105
|
+
if (signal?.aborted) {
|
|
106
|
+
reject(cancellation(signal.reason));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const cancel = () => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
reject(cancellation(signal?.reason));
|
|
112
|
+
};
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
signal?.removeEventListener('abort', cancel);
|
|
115
|
+
resolve();
|
|
116
|
+
}, ms);
|
|
117
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
118
|
+
});
|
|
119
|
+
/** Reads a response body. The platform fetch aborts the body with the request's signal, so a
|
|
120
|
+
* caller abort mid-read surfaces as the cancellation instead of a raw abort. */
|
|
121
|
+
async function readBody(read, signal) {
|
|
122
|
+
try {
|
|
123
|
+
return await read;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (signal?.aborted)
|
|
127
|
+
throw cancellation(signal.reason);
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Records a failed PubChem request, with its URL, in the server's own log. The error the
|
|
132
|
+
* caller receives deliberately omits the URL, so this line is the only place an operator can
|
|
133
|
+
* see which route failed. The module `logger` writes server-side only — `ctx.log` would also
|
|
134
|
+
* forward the line to the client as `notifications/message`. */
|
|
135
|
+
function logFailedRequest(level, url, method, detail) {
|
|
136
|
+
const summary = 'status' in detail ? `HTTP ${detail.status}` : detail.error;
|
|
137
|
+
logger[level](`PubChem request failed: ${summary}`, requestContextService.createRequestContext({
|
|
138
|
+
operation: 'PubChemClient.fetch',
|
|
139
|
+
additionalContext: { url, method, ...detail },
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
66
142
|
/** PubChem returns different field names than the request parameter names for some properties.
|
|
67
143
|
* Request IsomericSMILES → response key "SMILES" (includes stereochemistry).
|
|
68
144
|
* Request CanonicalSMILES → response key "ConnectivitySMILES" (connectivity only). */
|
|
@@ -401,18 +477,26 @@ export class PubChemClient {
|
|
|
401
477
|
sdqBase = 'https://pubchem.ncbi.nlm.nih.gov/sdq/sdqagent.cgi';
|
|
402
478
|
rateLimiter = new RateLimiter(5);
|
|
403
479
|
// ── Core HTTP ────────────────────────────────────────────────────
|
|
404
|
-
/** Shared HTTP core: rate-limit, 30s timeout, retry once on 5xx
|
|
405
|
-
* network error, and surface a clean timeout message. Returns
|
|
406
|
-
* extract the body (JSON, bytes, or text). Centralizing this keeps
|
|
407
|
-
* one resilience contract — the divergence that left fetchBinary
|
|
408
|
-
* timeout message (#16) cannot recur.
|
|
409
|
-
|
|
480
|
+
/** Shared HTTP core: rate-limit, 30s timeout, retry once on 5xx (except a rejected search
|
|
481
|
+
* query) and once on a transient network error, and surface a clean timeout message. Returns
|
|
482
|
+
* the ok Response; callers extract the body (JSON, bytes, or text). Centralizing this keeps
|
|
483
|
+
* every fetch variant on one resilience contract — the divergence that left fetchBinary
|
|
484
|
+
* without retry or a clean timeout message (#16) cannot recur.
|
|
485
|
+
*
|
|
486
|
+
* `init.signal` is the caller's cancellation, combined with the per-attempt timeout. Once it
|
|
487
|
+
* aborts — queued for a slot, mid-fetch, or in a retry backoff — the request ends with
|
|
488
|
+
* `RequestCancelled`: no retry, and no failed-request log, since nothing failed upstream. */
|
|
489
|
+
async fetchResponse(url, { signal, ...init }) {
|
|
490
|
+
const method = init.method ?? 'GET';
|
|
410
491
|
for (let attempt = 0;; attempt++) {
|
|
411
|
-
await this.rateLimiter.acquire();
|
|
412
|
-
const
|
|
413
|
-
const timeoutId = setTimeout(() =>
|
|
492
|
+
await this.rateLimiter.acquire(signal);
|
|
493
|
+
const timeout = new AbortController();
|
|
494
|
+
const timeoutId = setTimeout(() => timeout.abort(), 30_000);
|
|
414
495
|
try {
|
|
415
|
-
const response = await fetch(url, {
|
|
496
|
+
const response = await fetch(url, {
|
|
497
|
+
...init,
|
|
498
|
+
signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal,
|
|
499
|
+
});
|
|
416
500
|
if (response.ok)
|
|
417
501
|
return response;
|
|
418
502
|
const text = await response.text();
|
|
@@ -427,26 +511,42 @@ export class PubChemClient {
|
|
|
427
511
|
service: 'PubChem',
|
|
428
512
|
data: { fault },
|
|
429
513
|
});
|
|
430
|
-
// Retry once on 5xx unless the framework marks the failure as permanent
|
|
431
|
-
|
|
432
|
-
|
|
514
|
+
// Retry once on 5xx unless the framework marks the failure as permanent or PubChem
|
|
515
|
+
// has rejected the search query itself, which faults identically on every re-send.
|
|
516
|
+
if (response.status >= 500 &&
|
|
517
|
+
error.data?.retryable !== false &&
|
|
518
|
+
fault !== SEARCH_REJECTED_FAULT &&
|
|
519
|
+
attempt < 1) {
|
|
520
|
+
await sleep(1000 * 2 ** attempt, signal);
|
|
433
521
|
continue;
|
|
434
522
|
}
|
|
523
|
+
// A 404 is routine — most callers read it as "no data" — so it logs below warning.
|
|
524
|
+
logFailedRequest(response.status === 404 ? 'debug' : 'warning', url, method, {
|
|
525
|
+
status: response.status,
|
|
526
|
+
fault,
|
|
527
|
+
});
|
|
435
528
|
throw error;
|
|
436
529
|
}
|
|
437
530
|
catch (error) {
|
|
438
|
-
//
|
|
531
|
+
// The caller withdrew the call: whatever this attempt rejected with, it is over.
|
|
532
|
+
if (signal?.aborted)
|
|
533
|
+
throw cancellation(signal.reason);
|
|
534
|
+
// HTTP errors are already classified and logged — surface them, don't retry.
|
|
439
535
|
if (error instanceof McpError)
|
|
440
536
|
throw error;
|
|
441
537
|
// Retry once on network errors
|
|
442
538
|
if (attempt < 1) {
|
|
443
|
-
await sleep(1000 * 2 ** attempt);
|
|
539
|
+
await sleep(1000 * 2 ** attempt, signal);
|
|
444
540
|
continue;
|
|
445
541
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
542
|
+
// With the caller's signal ruled out, an abort can only be this attempt's timeout.
|
|
543
|
+
const failure = error instanceof Error && error.name === 'AbortError'
|
|
544
|
+
? new Error('PubChem request timed out (30s)')
|
|
545
|
+
: error;
|
|
546
|
+
logFailedRequest('warning', url, method, {
|
|
547
|
+
error: failure instanceof Error ? failure.message : String(failure),
|
|
548
|
+
});
|
|
549
|
+
throw failure;
|
|
450
550
|
}
|
|
451
551
|
finally {
|
|
452
552
|
clearTimeout(timeoutId);
|
|
@@ -455,16 +555,16 @@ export class PubChemClient {
|
|
|
455
555
|
}
|
|
456
556
|
async fetchJson(url, init) {
|
|
457
557
|
const response = await this.fetchResponse(url, init);
|
|
458
|
-
return (await response.json());
|
|
558
|
+
return (await readBody(response.json(), init.signal));
|
|
459
559
|
}
|
|
460
|
-
async fetchBinary(url) {
|
|
461
|
-
const response = await this.fetchResponse(url);
|
|
462
|
-
return response.arrayBuffer();
|
|
560
|
+
async fetchBinary(url, signal) {
|
|
561
|
+
const response = await this.fetchResponse(url, { signal });
|
|
562
|
+
return readBody(response.arrayBuffer(), signal);
|
|
463
563
|
}
|
|
464
564
|
/** Fetch a text/plain body (e.g. an SDF record). Non-2xx is classified and thrown. */
|
|
465
|
-
async fetchText(url) {
|
|
466
|
-
const response = await this.fetchResponse(url);
|
|
467
|
-
return response.text();
|
|
565
|
+
async fetchText(url, signal) {
|
|
566
|
+
const response = await this.fetchResponse(url, { signal });
|
|
567
|
+
return readBody(response.text(), signal);
|
|
468
568
|
}
|
|
469
569
|
// ── CID Resolution ──────────────────────────────────────────────
|
|
470
570
|
/** Fetch CID list, with automatic ListKey polling for async searches.
|
|
@@ -478,8 +578,8 @@ export class PubChemClient {
|
|
|
478
578
|
try {
|
|
479
579
|
const data = await this.fetchJson(url, init);
|
|
480
580
|
if ('Waiting' in data)
|
|
481
|
-
return this.pollListKey(data.Waiting.ListKey, maxRecords);
|
|
482
|
-
return data.IdentifierList.CID;
|
|
581
|
+
return this.pollListKey(data.Waiting.ListKey, maxRecords, init.signal);
|
|
582
|
+
return realCids(data.IdentifierList.CID);
|
|
483
583
|
}
|
|
484
584
|
catch (error) {
|
|
485
585
|
if (isNotFound(error))
|
|
@@ -490,17 +590,18 @@ export class PubChemClient {
|
|
|
490
590
|
/** Poll a PubChem ListKey until results are ready.
|
|
491
591
|
*
|
|
492
592
|
* `listkey_count` asks PubChem to trim the page; the slice enforces the same bound
|
|
493
|
-
* locally, so the caller's saturation test holds whether or not the parameter is honored.
|
|
494
|
-
|
|
593
|
+
* locally, so the caller's saturation test holds whether or not the parameter is honored.
|
|
594
|
+
* A cancellation ends the wait before the next poll. */
|
|
595
|
+
async pollListKey(listKey, maxRecords, signal, maxAttempts = 20) {
|
|
495
596
|
const query = maxRecords === undefined ? '' : `?listkey_count=${maxRecords}`;
|
|
496
597
|
const pollUrl = `${this.pugBase}/compound/listkey/${listKey}/cids/JSON${query}`;
|
|
497
598
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
498
|
-
await sleep(1500);
|
|
599
|
+
await sleep(1500, signal);
|
|
499
600
|
try {
|
|
500
|
-
const data = await this.fetchJson(pollUrl);
|
|
601
|
+
const data = await this.fetchJson(pollUrl, { signal });
|
|
501
602
|
if ('Waiting' in data)
|
|
502
603
|
continue;
|
|
503
|
-
const cids = data.IdentifierList.CID;
|
|
604
|
+
const cids = realCids(data.IdentifierList.CID);
|
|
504
605
|
return maxRecords === undefined ? cids : cids.slice(0, maxRecords);
|
|
505
606
|
}
|
|
506
607
|
catch (error) {
|
|
@@ -511,18 +612,36 @@ export class PubChemClient {
|
|
|
511
612
|
}
|
|
512
613
|
throw new Error('PubChem async search timed out after polling');
|
|
513
614
|
}
|
|
514
|
-
|
|
515
|
-
|
|
615
|
+
/** Resolve one caller-supplied identifier to its CIDs; [] when PubChem has no match.
|
|
616
|
+
*
|
|
617
|
+
* The identifier is the only variable in these requests, so an HTTP 400 is PubChem rejecting
|
|
618
|
+
* that identifier — for SMILES, a string it cannot standardize into a structure, including
|
|
619
|
+
* well-formed SMILES it cannot process such as a "*" wildcard atom. It is re-thrown as a
|
|
620
|
+
* ValidationError tagged `identifier_rejected`, so a batch caller can set that one input
|
|
621
|
+
* aside. Every other failure — 5xx, rate limit, timeout — keeps its own classification. */
|
|
622
|
+
async lookupIdentifier(url, init) {
|
|
623
|
+
try {
|
|
624
|
+
return await this.fetchCids(url, init);
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
if (!isBadRequest(error))
|
|
628
|
+
throw error;
|
|
629
|
+
throw validationError('PubChem could not interpret the identifier.', { reason: 'identifier_rejected', fault: faultOf(error) }, { cause: error });
|
|
630
|
+
}
|
|
516
631
|
}
|
|
517
|
-
|
|
518
|
-
return this.
|
|
632
|
+
searchByName(name, signal) {
|
|
633
|
+
return this.lookupIdentifier(`${this.pugBase}/compound/name/${encodeURIComponent(name)}/cids/JSON`, { signal });
|
|
634
|
+
}
|
|
635
|
+
searchBySmiles(smiles, signal) {
|
|
636
|
+
return this.lookupIdentifier(`${this.pugBase}/compound/smiles/cids/JSON`, {
|
|
519
637
|
method: 'POST',
|
|
520
638
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
521
639
|
body: new URLSearchParams({ smiles }).toString(),
|
|
640
|
+
signal,
|
|
522
641
|
});
|
|
523
642
|
}
|
|
524
|
-
searchByInchiKey(inchikey) {
|
|
525
|
-
return this.
|
|
643
|
+
searchByInchiKey(inchikey, signal) {
|
|
644
|
+
return this.lookupIdentifier(`${this.pugBase}/compound/inchikey/${encodeURIComponent(inchikey)}/cids/JSON`, { signal });
|
|
526
645
|
}
|
|
527
646
|
/** Formula search, bounded server-side by `maxRecords`.
|
|
528
647
|
*
|
|
@@ -535,15 +654,16 @@ export class PubChemClient {
|
|
|
535
654
|
* `maxRecords` is saturated, and the true total is not recoverable — PubChem returns the
|
|
536
655
|
* CID list alone, with no match count beside it. Callers that need to distinguish the two
|
|
537
656
|
* compare the returned length against the cap they passed. */
|
|
538
|
-
searchByFormula(formula, allowOther, maxRecords) {
|
|
657
|
+
searchByFormula(formula, allowOther, maxRecords, signal) {
|
|
539
658
|
const params = new URLSearchParams({ MaxRecords: String(maxRecords) });
|
|
540
659
|
if (allowOther)
|
|
541
660
|
params.set('AllowOtherElements', 'true');
|
|
542
|
-
return this.fetchCids(`${this.pugBase}/compound/fastformula/${encodeURIComponent(formula)}/cids/JSON?${params}`,
|
|
661
|
+
return mapRejectedSearch(this.fetchCids(`${this.pugBase}/compound/fastformula/${encodeURIComponent(formula)}/cids/JSON?${params}`, { signal }, maxRecords), 'PubChem could not run the formula search on this formula.', 'Write the formula in Hill notation with valid element symbols, for example "C6H12O6" or "CaH2O2".');
|
|
543
662
|
}
|
|
544
663
|
/** Substructure, superstructure, and 2D-similarity search, bounded server-side by
|
|
545
|
-
* `maxRecords`. Same cap contract as {@link searchByFormula}
|
|
546
|
-
|
|
664
|
+
* `maxRecords`. Same cap contract as {@link searchByFormula}; a query PubChem cannot search
|
|
665
|
+
* on throws `search_query_rejected` the same way. */
|
|
666
|
+
searchByStructure(mode, query, queryType, threshold, maxRecords, signal) {
|
|
547
667
|
const endpoint = mode === 'similarity'
|
|
548
668
|
? 'fastsimilarity_2d'
|
|
549
669
|
: mode === 'substructure'
|
|
@@ -553,18 +673,19 @@ export class PubChemClient {
|
|
|
553
673
|
if (mode === 'similarity')
|
|
554
674
|
params.set('Threshold', String(threshold ?? 90));
|
|
555
675
|
if (queryType === 'cid') {
|
|
556
|
-
return this.fetchCids(`${this.pugBase}/compound/${endpoint}/cid/${query}/cids/JSON?${params}`,
|
|
676
|
+
return mapRejectedSearch(this.fetchCids(`${this.pugBase}/compound/${endpoint}/cid/${query}/cids/JSON?${params}`, { signal }, maxRecords), `PubChem could not run the ${mode} search on CID ${query}.`, `Confirm CID ${query} exists with pubchem_get_compound_details, or pass the structure as a SMILES query with queryType "smiles".`);
|
|
557
677
|
}
|
|
558
678
|
// POST for SMILES to avoid encoding issues
|
|
559
679
|
const url = `${this.pugBase}/compound/${endpoint}/smiles/cids/JSON?${params}`;
|
|
560
|
-
return this.fetchCids(url, {
|
|
680
|
+
return mapRejectedSearch(this.fetchCids(url, {
|
|
561
681
|
method: 'POST',
|
|
562
682
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
563
683
|
body: new URLSearchParams({ smiles: query }).toString(),
|
|
564
|
-
|
|
684
|
+
signal,
|
|
685
|
+
}, maxRecords), `PubChem could not run the ${mode} search on this SMILES.`, 'Check the SMILES syntax (balanced ring closures and parentheses, valid element symbols) and remove any "*" wildcard atoms, which PubChem structure search does not accept.');
|
|
565
686
|
}
|
|
566
687
|
// ── Compound Data ───────────────────────────────────────────────
|
|
567
|
-
async getProperties(cids, properties) {
|
|
688
|
+
async getProperties(cids, properties, signal) {
|
|
568
689
|
if (cids.length === 0 || properties.length === 0)
|
|
569
690
|
return [];
|
|
570
691
|
const propsPath = properties.join(',');
|
|
@@ -575,15 +696,16 @@ export class PubChemClient {
|
|
|
575
696
|
method: 'POST',
|
|
576
697
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
577
698
|
body: new URLSearchParams({ cid: cidStr }).toString(),
|
|
699
|
+
signal,
|
|
578
700
|
})
|
|
579
|
-
: await this.fetchJson(`${this.pugBase}/compound/cid/${cidStr}/property/${propsPath}/JSON
|
|
701
|
+
: await this.fetchJson(`${this.pugBase}/compound/cid/${cidStr}/property/${propsPath}/JSON`, { signal });
|
|
580
702
|
// PubChem returns different field names than the request names for some properties.
|
|
581
703
|
// Normalize so consumers see the names they requested.
|
|
582
704
|
return data.PropertyTable.Properties.map(normalizePropertyNames);
|
|
583
705
|
}
|
|
584
|
-
async getSynonyms(cid) {
|
|
706
|
+
async getSynonyms(cid, signal) {
|
|
585
707
|
try {
|
|
586
|
-
const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/synonyms/JSON
|
|
708
|
+
const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/synonyms/JSON`, { signal });
|
|
587
709
|
return data.InformationList.Information[0]?.Synonym ?? [];
|
|
588
710
|
}
|
|
589
711
|
catch (error) {
|
|
@@ -592,10 +714,10 @@ export class PubChemClient {
|
|
|
592
714
|
throw error;
|
|
593
715
|
}
|
|
594
716
|
}
|
|
595
|
-
async getImage(cid, size = 'large') {
|
|
717
|
+
async getImage(cid, size = 'large', signal) {
|
|
596
718
|
const sizeParam = size === 'small' ? '?image_size=small' : '?image_size=large';
|
|
597
719
|
try {
|
|
598
|
-
return await this.fetchBinary(`${this.pugBase}/compound/cid/${cid}/PNG${sizeParam}
|
|
720
|
+
return await this.fetchBinary(`${this.pugBase}/compound/cid/${cid}/PNG${sizeParam}`, signal);
|
|
599
721
|
}
|
|
600
722
|
catch (error) {
|
|
601
723
|
// The image endpoint returns binary, so absence can't be a structured success like
|
|
@@ -610,9 +732,9 @@ export class PubChemClient {
|
|
|
610
732
|
throw error;
|
|
611
733
|
}
|
|
612
734
|
}
|
|
613
|
-
async getXrefs(cid, xrefType) {
|
|
735
|
+
async getXrefs(cid, xrefType, signal) {
|
|
614
736
|
try {
|
|
615
|
-
const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/xrefs/${xrefType}/JSON
|
|
737
|
+
const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/xrefs/${xrefType}/JSON`, { signal });
|
|
616
738
|
const info = data.InformationList.Information[0];
|
|
617
739
|
if (!info)
|
|
618
740
|
return [];
|
|
@@ -626,9 +748,9 @@ export class PubChemClient {
|
|
|
626
748
|
}
|
|
627
749
|
}
|
|
628
750
|
// ── PUG View ────────────────────────────────────────────────────
|
|
629
|
-
async getDescription(cid) {
|
|
751
|
+
async getDescription(cid, signal) {
|
|
630
752
|
try {
|
|
631
|
-
const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Record+Description
|
|
753
|
+
const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Record+Description`, { signal });
|
|
632
754
|
const sections = data.Record.Section;
|
|
633
755
|
if (!sections)
|
|
634
756
|
return [];
|
|
@@ -659,9 +781,9 @@ export class PubChemClient {
|
|
|
659
781
|
* GHS classification" — a nonexistent CID and a real compound without safety data are
|
|
660
782
|
* different answers that call for different follow-up, and both used to surface as an
|
|
661
783
|
* absent result. */
|
|
662
|
-
async getSafetyData(cid) {
|
|
784
|
+
async getSafetyData(cid, signal) {
|
|
663
785
|
try {
|
|
664
|
-
const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Safety+and+Hazards
|
|
786
|
+
const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Safety+and+Hazards`, { signal });
|
|
665
787
|
const sections = data.Record.Section;
|
|
666
788
|
if (!sections)
|
|
667
789
|
return { status: 'no_ghs_data' };
|
|
@@ -721,9 +843,9 @@ export class PubChemClient {
|
|
|
721
843
|
throw error;
|
|
722
844
|
}
|
|
723
845
|
}
|
|
724
|
-
async getClassification(cid) {
|
|
846
|
+
async getClassification(cid, signal) {
|
|
725
847
|
try {
|
|
726
|
-
const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Pharmacology+and+Biochemistry
|
|
848
|
+
const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Pharmacology+and+Biochemistry`, { signal });
|
|
727
849
|
const sections = data.Record.Section;
|
|
728
850
|
if (!sections)
|
|
729
851
|
return null;
|
|
@@ -792,9 +914,9 @@ export class PubChemClient {
|
|
|
792
914
|
}
|
|
793
915
|
}
|
|
794
916
|
// ── Bioactivity ─────────────────────────────────────────────────
|
|
795
|
-
async getAssaySummary(cid) {
|
|
917
|
+
async getAssaySummary(cid, signal) {
|
|
796
918
|
try {
|
|
797
|
-
const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/assaysummary/JSON
|
|
919
|
+
const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/assaysummary/JSON`, { signal });
|
|
798
920
|
return this.parseAssayTable(data);
|
|
799
921
|
}
|
|
800
922
|
catch (error) {
|
|
@@ -871,11 +993,11 @@ export class PubChemClient {
|
|
|
871
993
|
return [...byAid.values()];
|
|
872
994
|
}
|
|
873
995
|
// ── Assay Search ────────────────────────────────────────────────
|
|
874
|
-
async searchAssaysByTarget(targetType, query) {
|
|
996
|
+
async searchAssaysByTarget(targetType, query, signal) {
|
|
875
997
|
// PubChem API expects "accession" not "proteinaccession"
|
|
876
998
|
const apiTargetType = targetType === 'proteinaccession' ? 'accession' : targetType;
|
|
877
999
|
try {
|
|
878
|
-
const data = await this.fetchJson(`${this.pugBase}/assay/target/${apiTargetType}/${encodeURIComponent(query)}/aids/JSON
|
|
1000
|
+
const data = await this.fetchJson(`${this.pugBase}/assay/target/${apiTargetType}/${encodeURIComponent(query)}/aids/JSON`, { signal });
|
|
879
1001
|
return data.IdentifierList.AID;
|
|
880
1002
|
}
|
|
881
1003
|
catch (error) {
|
|
@@ -885,7 +1007,7 @@ export class PubChemClient {
|
|
|
885
1007
|
}
|
|
886
1008
|
}
|
|
887
1009
|
// ── Entity Summaries ────────────────────────────────────────────
|
|
888
|
-
async getEntitySummary(entityType, identifier) {
|
|
1010
|
+
async getEntitySummary(entityType, identifier, signal) {
|
|
889
1011
|
const pathMap = {
|
|
890
1012
|
assay: `/assay/aid/${identifier}/summary/JSON`,
|
|
891
1013
|
gene: `/gene/geneid/${identifier}/summary/JSON`,
|
|
@@ -896,7 +1018,9 @@ export class PubChemClient {
|
|
|
896
1018
|
if (!path)
|
|
897
1019
|
throw new Error(`Unknown entity type: ${entityType}`);
|
|
898
1020
|
try {
|
|
899
|
-
const data = await this.fetchJson(`${this.pugBase}${path}
|
|
1021
|
+
const data = await this.fetchJson(`${this.pugBase}${path}`, {
|
|
1022
|
+
signal,
|
|
1023
|
+
});
|
|
900
1024
|
// Response shape: { XxxSummaries: { XxxSummary: [{...}] } }
|
|
901
1025
|
const summariesKey = Object.keys(data).find((k) => k.endsWith('Summaries'));
|
|
902
1026
|
if (!summariesKey)
|
|
@@ -912,9 +1036,8 @@ export class PubChemClient {
|
|
|
912
1036
|
if (isNotFound(error))
|
|
913
1037
|
return null;
|
|
914
1038
|
// PubChem returns HTTP 400 (not 404) for nonexistent entity IDs in some endpoints
|
|
915
|
-
if (error
|
|
1039
|
+
if (isBadRequest(error))
|
|
916
1040
|
return null;
|
|
917
|
-
}
|
|
918
1041
|
throw error;
|
|
919
1042
|
}
|
|
920
1043
|
}
|
|
@@ -923,13 +1046,17 @@ export class PubChemClient {
|
|
|
923
1046
|
* Drug-drug and target data live in PubChem SDQ external tables (drugbankddi, bioactivity);
|
|
924
1047
|
* drug-food is inline PUG View text. Each kind reads a window of `maxEntries` entries
|
|
925
1048
|
* starting at `offset` records into that kind's own stream; absent data for a kind
|
|
926
|
-
* contributes an empty page rather than erroring. */
|
|
927
|
-
async getInteractions(cid, kinds, maxEntries, offset) {
|
|
1049
|
+
* contributes an empty page rather than erroring. A cancellation fails the whole call. */
|
|
1050
|
+
async getInteractions(cid, kinds, maxEntries, offset, signal) {
|
|
928
1051
|
// Per-kind isolation: a failure in one source (upstream parse error, timeout, network)
|
|
929
1052
|
// must not discard the kinds that succeeded. Failures are reported, not thrown (#21).
|
|
930
1053
|
// Page state is recorded per kind from that kind's own result, so a failing kind leaves
|
|
931
1054
|
// the others' continuation signals untouched rather than zeroing them.
|
|
932
|
-
const settled = await Promise.allSettled(kinds.map((kind) => this.getInteractionsForKind(cid, kind, maxEntries, offset)));
|
|
1055
|
+
const settled = await Promise.allSettled(kinds.map((kind) => this.getInteractionsForKind(cid, kind, maxEntries, offset, signal)));
|
|
1056
|
+
// A cancelled kind is not a failed source — reporting it as one would hand a withdrawn
|
|
1057
|
+
// call a partial result.
|
|
1058
|
+
if (signal?.aborted)
|
|
1059
|
+
throw cancellation(signal.reason);
|
|
933
1060
|
const entries = [];
|
|
934
1061
|
const pages = [];
|
|
935
1062
|
const failedKinds = [];
|
|
@@ -950,18 +1077,18 @@ export class PubChemClient {
|
|
|
950
1077
|
});
|
|
951
1078
|
return { entries, pages, failedKinds };
|
|
952
1079
|
}
|
|
953
|
-
getInteractionsForKind(cid, kind, maxEntries, offset) {
|
|
1080
|
+
getInteractionsForKind(cid, kind, maxEntries, offset, signal) {
|
|
954
1081
|
switch (kind) {
|
|
955
1082
|
case 'drug-drug':
|
|
956
|
-
return this.getDrugDrugInteractions(cid, maxEntries, offset);
|
|
1083
|
+
return this.getDrugDrugInteractions(cid, maxEntries, offset, signal);
|
|
957
1084
|
case 'drug-food':
|
|
958
|
-
return this.getDrugFoodInteractions(cid, maxEntries, offset);
|
|
1085
|
+
return this.getDrugFoodInteractions(cid, maxEntries, offset, signal);
|
|
959
1086
|
case 'target':
|
|
960
|
-
return this.getTargetInteractions(cid, maxEntries, offset);
|
|
1087
|
+
return this.getTargetInteractions(cid, maxEntries, offset, signal);
|
|
961
1088
|
}
|
|
962
1089
|
}
|
|
963
|
-
async getDrugDrugInteractions(cid, maxEntries, offset) {
|
|
964
|
-
const { rows, totalCount } = await this.fetchSdq('drugbankddi', cid, ['cid', 'name2', 'descr'], maxEntries, offset);
|
|
1090
|
+
async getDrugDrugInteractions(cid, maxEntries, offset, signal) {
|
|
1091
|
+
const { rows, totalCount } = await this.fetchSdq('drugbankddi', cid, ['cid', 'name2', 'descr'], maxEntries, offset, signal);
|
|
965
1092
|
const entries = [];
|
|
966
1093
|
for (const row of rows) {
|
|
967
1094
|
const text = typeof row.descr === 'string' ? row.descr : '';
|
|
@@ -974,7 +1101,7 @@ export class PubChemClient {
|
|
|
974
1101
|
}
|
|
975
1102
|
return {
|
|
976
1103
|
entries,
|
|
977
|
-
totalRecords: await this.resolveSdqTotal('drugbankddi', cid, rows.length, totalCount, offset),
|
|
1104
|
+
totalRecords: await this.resolveSdqTotal('drugbankddi', cid, rows.length, totalCount, offset, signal),
|
|
978
1105
|
recordsConsumed: rows.length,
|
|
979
1106
|
};
|
|
980
1107
|
}
|
|
@@ -988,11 +1115,11 @@ export class PubChemClient {
|
|
|
988
1115
|
* Because most rows are dropped, the page position is an index into the activity records, not
|
|
989
1116
|
* into the entries returned — the walk records exactly how many rows it read so the next page
|
|
990
1117
|
* resumes at the first unread one. */
|
|
991
|
-
async getTargetInteractions(cid, maxEntries, offset) {
|
|
1118
|
+
async getTargetInteractions(cid, maxEntries, offset, signal) {
|
|
992
1119
|
// `targetname` is sparse (most rows are untargeted assay outcomes), so oversample and keep
|
|
993
1120
|
// the target-bearing rows, most-potent-first.
|
|
994
1121
|
const window = Math.min(Math.max(maxEntries * 4, 20), 100);
|
|
995
|
-
const { rows, totalCount } = await this.fetchSdq('bioactivity', cid, ['cid', 'targetname', 'acname', 'acqualifier', 'acvalue', 'aidsrcname'], window, offset, ['acvalue,asc']);
|
|
1122
|
+
const { rows, totalCount } = await this.fetchSdq('bioactivity', cid, ['cid', 'targetname', 'acname', 'acqualifier', 'acvalue', 'aidsrcname'], window, offset, signal, ['acvalue,asc']);
|
|
996
1123
|
const entries = [];
|
|
997
1124
|
// Collapse duplicate measurements of the same target/value reported across assays.
|
|
998
1125
|
const seen = new Set();
|
|
@@ -1027,14 +1154,14 @@ export class PubChemClient {
|
|
|
1027
1154
|
}
|
|
1028
1155
|
return {
|
|
1029
1156
|
entries,
|
|
1030
|
-
totalRecords: await this.resolveSdqTotal('bioactivity', cid, rows.length, totalCount, offset),
|
|
1157
|
+
totalRecords: await this.resolveSdqTotal('bioactivity', cid, rows.length, totalCount, offset, signal),
|
|
1031
1158
|
recordsConsumed,
|
|
1032
1159
|
};
|
|
1033
1160
|
}
|
|
1034
|
-
async getDrugFoodInteractions(cid, maxEntries, offset) {
|
|
1161
|
+
async getDrugFoodInteractions(cid, maxEntries, offset, signal) {
|
|
1035
1162
|
const empty = { entries: [], totalRecords: 0, recordsConsumed: 0 };
|
|
1036
1163
|
try {
|
|
1037
|
-
const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Drug-Food+Interactions
|
|
1164
|
+
const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Drug-Food+Interactions`, { signal });
|
|
1038
1165
|
const sections = data.Record.Section;
|
|
1039
1166
|
if (!sections)
|
|
1040
1167
|
return empty;
|
|
@@ -1078,7 +1205,7 @@ export class PubChemClient {
|
|
|
1078
1205
|
* throws, and the `status.error` check covers the same rejection arriving inside a 2xx body.
|
|
1079
1206
|
* An unparseable body throws with the collection and a body snippet attached, so per-kind
|
|
1080
1207
|
* isolation can name what failed. */
|
|
1081
|
-
async fetchSdq(collection, cid, columns, limit, offset
|
|
1208
|
+
async fetchSdq(collection, cid, columns, limit, offset, signal, order) {
|
|
1082
1209
|
const empty = { rows: [], totalCount: 0 };
|
|
1083
1210
|
const query = JSON.stringify({
|
|
1084
1211
|
select: columns,
|
|
@@ -1091,25 +1218,34 @@ export class PubChemClient {
|
|
|
1091
1218
|
const url = `${this.sdqBase}?infmt=json&outfmt=json&query=${encodeURIComponent(query)}`;
|
|
1092
1219
|
let body;
|
|
1093
1220
|
try {
|
|
1094
|
-
body = await this.fetchText(url);
|
|
1221
|
+
body = await this.fetchText(url, signal);
|
|
1095
1222
|
}
|
|
1096
1223
|
catch (error) {
|
|
1097
1224
|
if (isNotFound(error))
|
|
1098
1225
|
return empty;
|
|
1099
1226
|
throw error;
|
|
1100
1227
|
}
|
|
1228
|
+
// Both rejections below arrive inside a 2xx, past fetchResponse's failure logging.
|
|
1229
|
+
const reject = (message, data) => {
|
|
1230
|
+
logFailedRequest('warning', url, 'GET', { error: message });
|
|
1231
|
+
throw serviceUnavailable(message, data);
|
|
1232
|
+
};
|
|
1101
1233
|
let parsed;
|
|
1102
1234
|
try {
|
|
1103
1235
|
parsed = JSON.parse(body);
|
|
1104
1236
|
}
|
|
1105
1237
|
catch {
|
|
1106
|
-
|
|
1238
|
+
return reject(`PubChem SDQ returned unparseable JSON for collection "${collection}"`, {
|
|
1239
|
+
collection,
|
|
1240
|
+
cid,
|
|
1241
|
+
snippet: body.slice(0, 200),
|
|
1242
|
+
});
|
|
1107
1243
|
}
|
|
1108
1244
|
const set = parsed.SDQOutputSet?.[0];
|
|
1109
1245
|
if (!set)
|
|
1110
1246
|
return empty;
|
|
1111
1247
|
if (set.status?.error) {
|
|
1112
|
-
|
|
1248
|
+
return reject(`PubChem SDQ rejected the query for collection "${collection}": ${set.status.error}`, { collection, cid, sdqError: set.status.error });
|
|
1113
1249
|
}
|
|
1114
1250
|
return { rows: set.rows ?? [], totalCount: set.totalCount ?? 0 };
|
|
1115
1251
|
}
|
|
@@ -1119,18 +1255,18 @@ export class PubChemClient {
|
|
|
1119
1255
|
* answers `totalCount` 0 — so an offset that overshoots would otherwise report "no records"
|
|
1120
1256
|
* for a compound that has thousands. A one-row probe from the top recovers the real bound,
|
|
1121
1257
|
* and only runs on that case. */
|
|
1122
|
-
async resolveSdqTotal(collection, cid, rowsReturned, reportedTotal, offset) {
|
|
1258
|
+
async resolveSdqTotal(collection, cid, rowsReturned, reportedTotal, offset, signal) {
|
|
1123
1259
|
if (rowsReturned > 0 || offset === 0)
|
|
1124
1260
|
return reportedTotal;
|
|
1125
|
-
const { totalCount } = await this.fetchSdq(collection, cid, ['cid'], 1);
|
|
1261
|
+
const { totalCount } = await this.fetchSdq(collection, cid, ['cid'], 1, 0, signal);
|
|
1126
1262
|
return totalCount;
|
|
1127
1263
|
}
|
|
1128
1264
|
// ── 3D Structure ────────────────────────────────────────────────
|
|
1129
1265
|
/** Fetch the default 3D conformer as raw V2000 SDF text. Throws a typed not-found when
|
|
1130
1266
|
* PubChem has no computed 3D coordinates (large molecules, mixtures, undefined salts). */
|
|
1131
|
-
async getSdf3d(cid) {
|
|
1267
|
+
async getSdf3d(cid, signal) {
|
|
1132
1268
|
try {
|
|
1133
|
-
return await this.fetchText(`${this.pugBase}/compound/cid/${cid}/record/SDF?record_type=3d
|
|
1269
|
+
return await this.fetchText(`${this.pugBase}/compound/cid/${cid}/record/SDF?record_type=3d`, signal);
|
|
1134
1270
|
}
|
|
1135
1271
|
catch (error) {
|
|
1136
1272
|
if (isNotFound(error)) {
|
|
@@ -1146,9 +1282,9 @@ export class PubChemClient {
|
|
|
1146
1282
|
}
|
|
1147
1283
|
}
|
|
1148
1284
|
/** List the conformer IDs PubChem has computed for a compound. Returns [] on not-found. */
|
|
1149
|
-
async getConformerIds(cid) {
|
|
1285
|
+
async getConformerIds(cid, signal) {
|
|
1150
1286
|
try {
|
|
1151
|
-
const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/conformers/JSON
|
|
1287
|
+
const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/conformers/JSON`, { signal });
|
|
1152
1288
|
return data.InformationList.Information[0]?.ConformerID ?? [];
|
|
1153
1289
|
}
|
|
1154
1290
|
catch (error) {
|