From 002a362541086ba5e2d0822d2135a2d8f40c843b Mon Sep 17 00:00:00 2001 From: Manuel Holtgrewe Date: Tue, 5 Mar 2024 07:53:15 +0100 Subject: [PATCH] feat: make components more robust in case of store/api errors (#160) (#161) --- src/api/annonars/client.spec.ts | 35 +++++- src/api/annonars/client.ts | 104 +++++++++++++----- .../__snapshots__/client.spec.ts.snap | 2 +- src/api/cadaPrio/client.spec.ts | 37 ++++++- src/api/cadaPrio/client.ts | 19 +++- src/api/common.ts | 47 ++++++++ src/api/dotty/client.spec.ts | 35 +++++- src/api/dotty/client.ts | 27 +++-- src/api/index.ts | 3 +- .../mehari/__snapshots__/client.spec.ts.snap | 2 +- src/api/mehari/client.spec.ts | 35 +++++- src/api/mehari/client.ts | 56 +++++++--- src/api/pubtator/client.ts | 21 ++-- src/api/variantValidator/client.spec.ts | 35 +++++- src/api/variantValidator/client.ts | 18 ++- src/api/viguno/client.spec.ts | 35 +++++- src/api/viguno/client.ts | 92 +++++++++++++--- .../GeneLiteratureCard/GeneLiteratureCard.vue | 7 ++ .../SeqvarToolsCard/SeqvarToolsCard.vue | 8 +- .../SeqvarVariantValidatorCard.vue | 7 ++ .../StrucvarToolsCard/StrucvarToolsCard.vue | 8 +- src/lib/urlConfig.ts | 28 +++++ .../geneInfo/__snapshots__/store.spec.ts.snap | 43 +++++--- src/stores/pubtator/store.ts | 8 +- src/vitest.setup.ts | 11 +- 25 files changed, 596 insertions(+), 127 deletions(-) create mode 100644 src/api/common.ts diff --git a/src/api/annonars/client.spec.ts b/src/api/annonars/client.spec.ts index a570c3c..8fab8a0 100644 --- a/src/api/annonars/client.spec.ts +++ b/src/api/annonars/client.spec.ts @@ -1,9 +1,10 @@ import fs from 'fs' import path from 'path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import createFetchMock from 'vitest-fetch-mock' import { LinearStrucvarImpl, SeqvarImpl } from '../../lib/genomicVars' +import { setupUrlConfigForTesting, urlConfig } from '../../lib/urlConfig' import { Record as GeneInfoRecord } from '../../pbs/annonars/genes/base' import { AnnonarsClient } from './client' import { ClinvarSvQueryResponse } from './types' @@ -32,6 +33,38 @@ const fetchMocker = createFetchMock(vi) /** Example Sequence Variant */ const seqvar = new SeqvarImpl('grch37', '1', 123, 'A', 'G') +describe.concurrent('AnnonarsClient.construct()', () => { + afterEach(() => { + setupUrlConfigForTesting() + }) + + it('constructs correctly with default base URL', () => { + // act: + const client = new AnnonarsClient() + + // assert: + expect(client).toBeDefined() + }) + + it('constructs correctly with custom base URL', () => { + // act: + const client = new AnnonarsClient('http://localhost:8080') + + // assert: + expect(client).toBeDefined() + }) + + it('throws error if no base URL is configured', () => { + // arrange: + urlConfig.baseUrlAnnonars = undefined + + // (guarded) + expect(() => new AnnonarsClient(undefined)).toThrow( + 'Configuration error: API base URL not configured' + ) + }) +}) + describe.concurrent('AnnonarsClient.fetchGeneInfo()', () => { beforeEach(() => { fetchMocker.enableMocks() diff --git a/src/api/annonars/client.ts b/src/api/annonars/client.ts index ac15d33..55b01ad 100644 --- a/src/api/annonars/client.ts +++ b/src/api/annonars/client.ts @@ -4,6 +4,7 @@ import type { Seqvar, Strucvar } from '../../lib/genomicVars' import { urlConfig } from '../../lib/urlConfig' import { ClinvarPerGeneRecord } from '../../pbs/annonars/clinvar/per_gene' import { Record as GeneInfoRecord } from '../../pbs/annonars/genes/base' +import { ConfigError, InvalidResponseContent, StatusCodeNotOk } from '../common' import { ClinvarSvQueryResponse, GeneInfoResult, @@ -21,14 +22,14 @@ export class AnnonarsClient { * @param apiBaseUrl * API base to the backend, excluding trailing `/`. * The default is declared in '@/lib/urlConfig`. - * @throws Error if the API base URL is not configured. + * @throws ConfigError if the API base URL is not configured. */ constructor(apiBaseUrl?: string) { if (apiBaseUrl !== undefined || urlConfig.baseUrlAnnonars !== undefined) { // @ts-ignore this.apiBaseUrl = apiBaseUrl ?? urlConfig.baseUrlAnnonars } else { - throw new Error('Configuration error: API base URL not configured') + throw new ConfigError('Configuration error: API base URL not configured') } } @@ -36,19 +37,32 @@ export class AnnonarsClient { * Fetch gene information via annonars REST API. * * @param hgncId HGNC ID, e.g., `"HGNC:26467"`. + * @returns Promise with gene information. + * @throws StatusCodeNotOk if the request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async fetchGeneInfo(hgncId: string): Promise { const response = await fetch(`${this.apiBaseUrl}/genes/info?hgnc_id=${hgncId}`, { method: 'GET' }) - const responseJson = await response.json() - return GeneInfoResult.fromJson(responseJson) + if (!response.ok) { + throw new StatusCodeNotOk(`failed to fetch gene info: ${response.statusText}`) + } + try { + const responseJson = await response.json() + return GeneInfoResult.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse gene info response: ${e}`) + } } /** * Fetch variant information via annonars and mehari REST APIs. * * @param seqvar The variant to retrieve the information for. + * @returns Promise with variant information. + * @throws StatusCodeNotOk if the request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async fetchVariantInfo(seqvar: Seqvar): Promise { const { genomeBuild, chrom, pos, del, ins } = seqvar @@ -66,30 +80,40 @@ export class AnnonarsClient { method: 'GET' }) if (!response.ok) { - throw new Error(`failed to fetch variant info: ${response.statusText}`) + throw new StatusCodeNotOk(`failed to fetch variant info: ${response.statusText}`) + } + try { + const responseJson = await response.json() + return SeqvarInfoResponse.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse variant info response: ${e}`) } - const responseJson = await response.json() - return SeqvarInfoResponse.fromJson(responseJson) } /** * Fetch per-gene ClinVar information via annonars REST API. * - * @param hgncId - * @returns + * @param hgncId HGNC ID, e.g., `"HGNC:26467"`. + * @returns Promise with per-gene ClinVar information. + * @throws StatusCodeNotOk if the request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async fetchGeneClinvarInfo(hgncId: string): Promise { const response = await fetch(`${this.apiBaseUrl}/genes/clinvar?hgnc_id=${hgncId}`, { method: 'GET' }) if (!response.ok) { - throw new Error(`failed to fetch gene clinvar info: ${response.statusText}`) + throw new StatusCodeNotOk(`failed to fetch gene clinvar info: ${response.statusText}`) } const responseJson = await response.json() if (responseJson?.genes && responseJson?.genes[hgncId]) { - return ClinvarPerGeneRecord.fromJson(responseJson.genes[hgncId]) + try { + return ClinvarPerGeneRecord.fromJson(responseJson.genes[hgncId]) + } catch (e) { + throw new InvalidResponseContent(`failed to parse gene clinvar info response: ${e}`) + } } else { - throw new Error(`failed to fetch gene clinvar info for HGNC ID: ${hgncId}`) + throw new InvalidResponseContent(`failed to fetch gene clinvar info for HGNC ID: ${hgncId}`) } } @@ -98,17 +122,22 @@ export class AnnonarsClient { * * @param query Query string to search for. * @returns Promise with gene search response. - * @throws Error if the request fails. + * @throws StatusCodeNotOk if the request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async fetchGenes(query: string): Promise { const response = await fetch(`${this.apiBaseUrl}/genes/search?q=${query}`, { method: 'GET' }) if (!response.ok) { - throw new Error(`failed to fetch genes: ${response.statusText}`) + throw new StatusCodeNotOk(`failed to fetch genes: ${response.statusText}`) + } + try { + const responseJson = await response.json() + return GeneSearchResponse.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse gene search response: ${e}`) } - const responseJson = await response.json() - return GeneSearchResponse.fromJson(responseJson) } /** @@ -117,6 +146,8 @@ export class AnnonarsClient { * @param hgncIds Array of HGNC IDs to use, e.g., `["HGNC:26467"]`. * @param chunkSize How many IDs to send in one request. * @returns Promise with an array of gene information objects. + * @throws StatusCodeNotOk if the request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async fetchGeneInfos(hgncIds: string[], chunkSize?: number): Promise { const hgncIdChunks = chunks(hgncIds, chunkSize ?? 10) @@ -128,26 +159,35 @@ export class AnnonarsClient { method: 'GET' }) if (!response.ok) { - throw new Error(`failed to fetch gene infos: ${response.statusText}`) + throw new StatusCodeNotOk(`failed to fetch gene infos: ${response.statusText}`) } return response }) - const responses = await Promise.all(promises) - const resultJsons = await Promise.all(responses.map((response) => response.json())) + try { + const responses = await Promise.all(promises) + const resultJsons = await Promise.all(responses.map((response) => response.json())) - const result: GeneInfoRecord[] = [] - resultJsons.forEach((chunk: any) => { - for (const value of Object.values(chunk.genes)) { - // @ts-ignore - result.push(GeneInfoRecord.fromJson(value as JsonValue)) - } - }) - return result + const result: GeneInfoRecord[] = [] + resultJsons.forEach((chunk: any) => { + for (const value of Object.values(chunk.genes)) { + // @ts-ignore + result.push(GeneInfoRecord.fromJson(value as JsonValue)) + } + }) + return result + } catch (e) { + throw new InvalidResponseContent(`failed to parse gene infos response: ${e}`) + } } /** * Fetch overlapping ClinVar strucvars via annonars REST API. + * + * @param strucvar The structural variant to retrieve the information for. + * @param pageSize The maximum number of records to return. + * @param minOverlap The minimum overlap required for a record to be returned. + * @returns Promise with ClinVar structural variant query response. */ async fetchClinvarStrucvars( strucvar: Strucvar, @@ -172,9 +212,13 @@ export class AnnonarsClient { method: 'GET' }) if (!response.ok) { - throw new Error(`failed to fetch clinvar strucvars: ${response.statusText}`) + throw new StatusCodeNotOk(`failed to fetch clinvar strucvars: ${response.statusText}`) + } + try { + const responseJson = await response.json() + return ClinvarSvQueryResponse.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse clinvar strucvars response: ${e}`) } - const responseJson = await response.json() - return ClinvarSvQueryResponse.fromJson(responseJson) } } diff --git a/src/api/cadaPrio/__snapshots__/client.spec.ts.snap b/src/api/cadaPrio/__snapshots__/client.spec.ts.snap index 16c8804..fb059f9 100644 --- a/src/api/cadaPrio/__snapshots__/client.spec.ts.snap +++ b/src/api/cadaPrio/__snapshots__/client.spec.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`CadaPrioClient > throws in case of fetching problems 1`] = `"{"entries":[{"hgncId":"HGNC:12786","ncbiGeneId":"7476","geneSymbol":"WNT7A","rank":1,"score":76.84823608398438},{"hgncId":"HGNC:11098","ncbiGeneId":"6595","geneSymbol":"SMARCA2","rank":2,"score":76.46592712402344},{"hgncId":"HGNC:25789","ncbiGeneId":"79631","geneSymbol":"EFL1","rank":3,"score":69.20905303955078},{"hgncId":"HGNC:11301","ncbiGeneId":"6729","geneSymbol":"SRP54","rank":4,"score":65.58743286132812},{"hgncId":"HGNC:5154","ncbiGeneId":"3248","geneSymbol":"HPGD","rank":5,"score":63.857845306396484},{"hgncId":"HGNC:4122","ncbiGeneId":"2588","geneSymbol":"GALNS","rank":6,"score":63.840911865234375},{"hgncId":"HGNC:27030","ncbiGeneId":"134218","geneSymbol":"DNAJC21","rank":7,"score":63.35689926147461},{"hgncId":"HGNC:7866","ncbiGeneId":"9241","geneSymbol":"NOG","rank":8,"score":63.207393646240234},{"hgncId":"HGNC:27230","ncbiGeneId":"157570","geneSymbol":"ESCO2","rank":9,"score":63.09525680541992},{"hgncId":"HGNC:2186","ncbiGeneId":"1301","geneSymbol":"COL11A1","rank":10,"score":61.24506378173828},{"hgncId":"HGNC:9587","ncbiGeneId":"9791","geneSymbol":"PTDSS1","rank":11,"score":60.995365142822266},{"hgncId":"HGNC:25507","ncbiGeneId":"55697","geneSymbol":"VAC14","rank":12,"score":60.21630859375},{"hgncId":"HGNC:11602","ncbiGeneId":"6926","geneSymbol":"TBX3","rank":13,"score":59.911136627197266},{"hgncId":"HGNC:4573","ncbiGeneId":"2892","geneSymbol":"GRIA3","rank":14,"score":59.312957763671875},{"hgncId":"HGNC:34016","ncbiGeneId":"100151683","geneSymbol":"RNU4ATAC","rank":15,"score":59.29935836791992},{"hgncId":"HGNC:4220","ncbiGeneId":"8200","geneSymbol":"GDF5","rank":16,"score":58.710994720458984},{"hgncId":"HGNC:23068","ncbiGeneId":"6399","geneSymbol":"TRAPPC2","rank":17,"score":56.57151412963867},{"hgncId":"HGNC:5956","ncbiGeneId":"3549","geneSymbol":"IHH","rank":18,"score":55.67450714111328},{"hgncId":"HGNC:16068","ncbiGeneId":"5116","geneSymbol":"PCNT","rank":19,"score":55.3505859375}]}"`; +exports[`CadaPrioClient.predictGeneImpact() > throws in case of fetching problems 1`] = `"{"entries":[{"hgncId":"HGNC:12786","ncbiGeneId":"7476","geneSymbol":"WNT7A","rank":1,"score":76.84823608398438},{"hgncId":"HGNC:11098","ncbiGeneId":"6595","geneSymbol":"SMARCA2","rank":2,"score":76.46592712402344},{"hgncId":"HGNC:25789","ncbiGeneId":"79631","geneSymbol":"EFL1","rank":3,"score":69.20905303955078},{"hgncId":"HGNC:11301","ncbiGeneId":"6729","geneSymbol":"SRP54","rank":4,"score":65.58743286132812},{"hgncId":"HGNC:5154","ncbiGeneId":"3248","geneSymbol":"HPGD","rank":5,"score":63.857845306396484},{"hgncId":"HGNC:4122","ncbiGeneId":"2588","geneSymbol":"GALNS","rank":6,"score":63.840911865234375},{"hgncId":"HGNC:27030","ncbiGeneId":"134218","geneSymbol":"DNAJC21","rank":7,"score":63.35689926147461},{"hgncId":"HGNC:7866","ncbiGeneId":"9241","geneSymbol":"NOG","rank":8,"score":63.207393646240234},{"hgncId":"HGNC:27230","ncbiGeneId":"157570","geneSymbol":"ESCO2","rank":9,"score":63.09525680541992},{"hgncId":"HGNC:2186","ncbiGeneId":"1301","geneSymbol":"COL11A1","rank":10,"score":61.24506378173828},{"hgncId":"HGNC:9587","ncbiGeneId":"9791","geneSymbol":"PTDSS1","rank":11,"score":60.995365142822266},{"hgncId":"HGNC:25507","ncbiGeneId":"55697","geneSymbol":"VAC14","rank":12,"score":60.21630859375},{"hgncId":"HGNC:11602","ncbiGeneId":"6926","geneSymbol":"TBX3","rank":13,"score":59.911136627197266},{"hgncId":"HGNC:4573","ncbiGeneId":"2892","geneSymbol":"GRIA3","rank":14,"score":59.312957763671875},{"hgncId":"HGNC:34016","ncbiGeneId":"100151683","geneSymbol":"RNU4ATAC","rank":15,"score":59.29935836791992},{"hgncId":"HGNC:4220","ncbiGeneId":"8200","geneSymbol":"GDF5","rank":16,"score":58.710994720458984},{"hgncId":"HGNC:23068","ncbiGeneId":"6399","geneSymbol":"TRAPPC2","rank":17,"score":56.57151412963867},{"hgncId":"HGNC:5956","ncbiGeneId":"3549","geneSymbol":"IHH","rank":18,"score":55.67450714111328},{"hgncId":"HGNC:16068","ncbiGeneId":"5116","geneSymbol":"PCNT","rank":19,"score":55.3505859375}]}"`; diff --git a/src/api/cadaPrio/client.spec.ts b/src/api/cadaPrio/client.spec.ts index 2e32dd8..4399e29 100644 --- a/src/api/cadaPrio/client.spec.ts +++ b/src/api/cadaPrio/client.spec.ts @@ -1,8 +1,9 @@ import fs from 'fs' import path from 'path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import createFetchMock from 'vitest-fetch-mock' +import { setupUrlConfigForTesting, urlConfig } from '../../lib/urlConfig' import { CadaPrioClient } from './client' /** Fixture with prediction results. */ @@ -13,7 +14,39 @@ const cadaPrioPredictResultJson = JSON.parse( /** Initialize mock for `fetch()`. */ const fetchMocker = createFetchMock(vi) -describe.concurrent('CadaPrioClient', () => { +describe.concurrent('CadaPrioClient.construct()', () => { + afterEach(() => { + setupUrlConfigForTesting() + }) + + it('constructs correctly with default base URL', () => { + // act: + const client = new CadaPrioClient() + + // assert: + expect(client).toBeDefined() + }) + + it('constructs correctly with custom base URL', () => { + // act: + const client = new CadaPrioClient('http://localhost:8080') + + // assert: + expect(client).toBeDefined() + }) + + it('throws error if no base URL is configured', () => { + // arrange: + urlConfig.baseUrlCadaPrio = undefined + + // (guarded) + expect(() => new CadaPrioClient(undefined)).toThrow( + 'Configuration error: API base URL not configured' + ) + }) +}) + +describe.concurrent('CadaPrioClient.predictGeneImpact()', () => { beforeEach(() => { fetchMocker.enableMocks() fetchMocker.resetMocks() diff --git a/src/api/cadaPrio/client.ts b/src/api/cadaPrio/client.ts index 7d38bce..34e1dac 100644 --- a/src/api/cadaPrio/client.ts +++ b/src/api/cadaPrio/client.ts @@ -1,4 +1,5 @@ import { urlConfig } from '../../lib/urlConfig' +import { ConfigError, InvalidResponseContent, StatusCodeNotOk } from '../common' import { Response } from './types' /** @@ -11,14 +12,14 @@ export class CadaPrioClient { * @param apiBaseUrl * API base to the backend, excluding trailing `/`. * The default is declared in '@/lib/urlConfig`. - * @throws Error if the API base URL is not configured. + * @throws ConfigError if the API base URL is not configured. */ constructor(apiBaseUrl?: string) { if (apiBaseUrl !== undefined || urlConfig.baseUrlCadaPrio !== undefined) { // @ts-ignore this.apiBaseUrl = apiBaseUrl ?? urlConfig.baseUrlCadaPrio } else { - throw new Error('Configuration error: API base URL not configured') + throw new ConfigError('Configuration error: API base URL not configured') } } @@ -28,7 +29,8 @@ export class CadaPrioClient { * @param hpoTerms HPO term IDs (e.g. `HP:0000001`) * @param geneSymbols Gene symbols (e.g. `BRCA1`) * @returns Promise with response of impact prediction. - * @throws Error if the API returns an error. + * @throws StatusCodeNotOk if the request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async predictGeneImpact(hpoTerms: string[], geneSymbols?: string[]): Promise { const geneSuffix = geneSymbols ? `&gene_symbols=${geneSymbols.join(',')}` : '' @@ -37,7 +39,14 @@ export class CadaPrioClient { const response = await fetch(url, { method: 'GET' }) - const responseJson = await response.json() - return Response.fromJson(responseJson) + if (!response.ok) { + throw new StatusCodeNotOk(`failed to fetch gene impact prediction: ${response.statusText}`) + } + try { + const responseJson = await response.json() + return Response.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse gene impact prediction response: ${e}`) + } } } diff --git a/src/api/common.ts b/src/api/common.ts new file mode 100644 index 0000000..15d554a --- /dev/null +++ b/src/api/common.ts @@ -0,0 +1,47 @@ +/** Common code for reev-frontend-lib/api */ + +/** + * Thrown on API configuration errors. + * + * Such configuration errors are programming errors, e.g., when the + * API base URL is not configured. + */ +export class ConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'ConfigurationError' + } +} + +/** + * Thrown when the query to the API failed. + */ +export class CallError extends Error { + constructor(message: string) { + super(message) + this.name = 'ApiCallError' + } +} + +/** + * Thrown when the API returned a non-OK status code (not 2xx). + */ +export class StatusCodeNotOk extends CallError { + constructor(message: string) { + super(message) + this.name = 'StatusCodeNotOk' + } +} + +/** + * Thrown when the resulting result was improperly formatted. + * + * E.g., when it was not valid JSON or did not have the expected + * structure. + */ +export class InvalidResponseContent extends CallError { + constructor(message: string) { + super(message) + this.name = 'InvalidResponseContent' + } +} diff --git a/src/api/dotty/client.spec.ts b/src/api/dotty/client.spec.ts index e83a1fd..f3e544b 100644 --- a/src/api/dotty/client.spec.ts +++ b/src/api/dotty/client.spec.ts @@ -1,8 +1,9 @@ import fs from 'fs' import path from 'path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import createFetchMock from 'vitest-fetch-mock' +import { setupUrlConfigForTesting, urlConfig } from '../../lib/urlConfig' import { DottyClient } from './client' /** Fixtures with response from API. */ @@ -16,6 +17,38 @@ const findTranscriptsResponseBrca1 = JSON.parse( /** Initialize mock for `fetch()`. */ const fetchMocker = createFetchMock(vi) +describe.concurrent('DottyClient.construct()', () => { + afterEach(() => { + setupUrlConfigForTesting() + }) + + it('constructs correctly with default base URL', () => { + // act: + const client = new DottyClient() + + // assert: + expect(client).toBeDefined() + }) + + it('constructs correctly with custom base URL', () => { + // act: + const client = new DottyClient('http://localhost:8080') + + // assert: + expect(client).toBeDefined() + }) + + it('throws error if no base URL is configured', () => { + // arrange: + urlConfig.baseUrlDotty = undefined + + // (guarded) + expect(() => new DottyClient(undefined)).toThrow( + 'Configuration error: API base URL not configured' + ) + }) +}) + describe.concurrent('DottyClient.toSpdi()', () => { beforeEach(() => { fetchMocker.enableMocks() diff --git a/src/api/dotty/client.ts b/src/api/dotty/client.ts index b39671a..cca4429 100644 --- a/src/api/dotty/client.ts +++ b/src/api/dotty/client.ts @@ -1,4 +1,5 @@ import { urlConfig } from '../../lib/urlConfig' +import { ConfigError, InvalidResponseContent } from '../common' import { SpdiResult, TranscriptResult } from './types' export class DottyClient { @@ -8,14 +9,14 @@ export class DottyClient { * @param apiBaseUrl * API base to the backend, excluding trailing `/`. * The default is declared in '@/lib/urlConfig`. - * @throws Error if the API base URL is not configured. + * @throws ConfigError if the API base URL is not configured. */ constructor(apiBaseUrl?: string) { if (apiBaseUrl !== undefined || urlConfig.baseUrlDotty !== undefined) { // @ts-ignore this.apiBaseUrl = apiBaseUrl ?? urlConfig.baseUrlDotty } else { - throw new Error('Configuration error: API base URL not configured') + throw new ConfigError('Configuration error: API base URL not configured') } } @@ -24,7 +25,8 @@ export class DottyClient { * * @param q Query to conert. * @param assembly Assembly to use. - * @returns Promise with the response form dotty or null if the request failed. + * @returns Promise with the response form dotty or `null` if the request failed. + * @throws InvalidResponseContent if the response is not valid JSON. */ async toSpdi(q: string, assembly: 'GRCh37' | 'GRCh38' = 'GRCh38'): Promise { const escapedQ = encodeURIComponent(q) @@ -33,8 +35,12 @@ export class DottyClient { method: 'GET' }) if (response.status == 200) { - const responseJson = await response.json() - return SpdiResult.fromJson(responseJson) + try { + const responseJson = await response.json() + return SpdiResult.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse SPDI response: ${e}`) + } } else { return null } @@ -45,7 +51,8 @@ export class DottyClient { * * @param hgncId HGNC ID to fetch transcripts for. * @param assembly Assembly to use. - * @returns Promise with the response form dotty or null if the request failed. + * @returns Promise with the response form dotty or `null` if the request failed. + * @throws InvalidResponseContent if the response is not valid JSON. */ async fetchTranscripts( hgncId: string, @@ -56,8 +63,12 @@ export class DottyClient { method: 'GET' }) if (response.status == 200) { - const responseJson = await response.json() - return TranscriptResult.fromJson(responseJson) + try { + const responseJson = await response.json() + return TranscriptResult.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse transcript response: ${e}`) + } } else { return null } diff --git a/src/api/index.ts b/src/api/index.ts index c53422a..c8b24b8 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,8 +1,9 @@ import * as annonars from './annonars' import * as cadaPrio from './cadaPrio' +import * as common from './common' import * as dotty from './dotty' import * as mehari from './mehari' import * as pubtator from './pubtator' import * as variantValidator from './variantValidator' -export { annonars, cadaPrio, dotty, mehari, pubtator, variantValidator } +export { annonars, cadaPrio, common, dotty, mehari, pubtator, variantValidator } diff --git a/src/api/mehari/__snapshots__/client.spec.ts.snap b/src/api/mehari/__snapshots__/client.spec.ts.snap index 4170e40..118f963 100644 --- a/src/api/mehari/__snapshots__/client.spec.ts.snap +++ b/src/api/mehari/__snapshots__/client.spec.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`MehariClient > properly returns gene transcripts 1`] = `"{"transcripts":[{"id":"NM_007294.4","geneSymbol":"BRCA1","geneId":"HGNC:1100","biotype":"TRANSCRIPT_BIOTYPE_CODING","tags":["TRANSCRIPT_TAG_MANE_SELECT","TRANSCRIPT_TAG_REF_SEQ_SELECT"],"protein":"NP_009225.1","startCodon":113,"stopCodon":5705,"genomeAlignments":[{"genomeBuild":"GENOME_BUILD_GRCH37","contig":"NC_000017.10","cdsStart":41197694,"cdsEnd":41276113,"strand":"STRAND_MINUS","exons":[{"altStartI":41196311,"altEndI":41197819,"ord":22,"altCdsStartI":5581,"altCdsEndI":7088,"cigar":"1508M"},{"altStartI":41199659,"altEndI":41199720,"ord":21,"altCdsStartI":5520,"altCdsEndI":5580,"cigar":"61M"},{"altStartI":41201137,"altEndI":41201211,"ord":20,"altCdsStartI":5446,"altCdsEndI":5519,"cigar":"74M"},{"altStartI":41203079,"altEndI":41203134,"ord":19,"altCdsStartI":5391,"altCdsEndI":5445,"cigar":"55M"},{"altStartI":41209068,"altEndI":41209152,"ord":18,"altCdsStartI":5307,"altCdsEndI":5390,"cigar":"84M"},{"altStartI":41215349,"altEndI":41215390,"ord":17,"altCdsStartI":5266,"altCdsEndI":5306,"cigar":"41M"},{"altStartI":41215890,"altEndI":41215968,"ord":16,"altCdsStartI":5188,"altCdsEndI":5265,"cigar":"78M"},{"altStartI":41219624,"altEndI":41219712,"ord":15,"altCdsStartI":5100,"altCdsEndI":5187,"cigar":"88M"},{"altStartI":41222944,"altEndI":41223255,"ord":14,"altCdsStartI":4789,"altCdsEndI":5099,"cigar":"311M"},{"altStartI":41226347,"altEndI":41226538,"ord":13,"altCdsStartI":4598,"altCdsEndI":4788,"cigar":"191M"},{"altStartI":41228504,"altEndI":41228631,"ord":12,"altCdsStartI":4471,"altCdsEndI":4597,"cigar":"127M"},{"altStartI":41234420,"altEndI":41234592,"ord":11,"altCdsStartI":4299,"altCdsEndI":4470,"cigar":"172M"},{"altStartI":41242960,"altEndI":41243049,"ord":10,"altCdsStartI":4210,"altCdsEndI":4298,"cigar":"89M"},{"altStartI":41243451,"altEndI":41246877,"ord":9,"altCdsStartI":784,"altCdsEndI":4209,"cigar":"3426M"},{"altStartI":41247862,"altEndI":41247939,"ord":8,"altCdsStartI":707,"altCdsEndI":783,"cigar":"77M"},{"altStartI":41249260,"altEndI":41249306,"ord":7,"altCdsStartI":661,"altCdsEndI":706,"cigar":"46M"},{"altStartI":41251791,"altEndI":41251897,"ord":6,"altCdsStartI":555,"altCdsEndI":660,"cigar":"106M"},{"altStartI":41256138,"altEndI":41256278,"ord":5,"altCdsStartI":415,"altCdsEndI":554,"cigar":"140M"},{"altStartI":41256884,"altEndI":41256973,"ord":4,"altCdsStartI":326,"altCdsEndI":414,"cigar":"89M"},{"altStartI":41258472,"altEndI":41258550,"ord":3,"altCdsStartI":248,"altCdsEndI":325,"cigar":"78M"},{"altStartI":41267742,"altEndI":41267796,"ord":2,"altCdsStartI":194,"altCdsEndI":247,"cigar":"54M"},{"altStartI":41276033,"altEndI":41276132,"ord":1,"altCdsStartI":95,"altCdsEndI":193,"cigar":"99M"},{"altStartI":41277287,"altEndI":41277381,"altCdsStartI":1,"altCdsEndI":94,"cigar":"94M"}]}]},{"id":"NM_007297.4","geneSymbol":"BRCA1","geneId":"HGNC:1100","biotype":"TRANSCRIPT_BIOTYPE_CODING","protein":"NP_009228.2","startCodon":194,"stopCodon":5645,"genomeAlignments":[{"genomeBuild":"GENOME_BUILD_GRCH37","contig":"NC_000017.10","cdsStart":41197694,"cdsEnd":41258543,"strand":"STRAND_MINUS","exons":[{"altStartI":41196311,"altEndI":41197819,"ord":21,"altCdsStartI":5521,"altCdsEndI":7028,"cigar":"1508M"},{"altStartI":41199659,"altEndI":41199720,"ord":20,"altCdsStartI":5460,"altCdsEndI":5520,"cigar":"61M"},{"altStartI":41201137,"altEndI":41201211,"ord":19,"altCdsStartI":5386,"altCdsEndI":5459,"cigar":"74M"},{"altStartI":41203079,"altEndI":41203134,"ord":18,"altCdsStartI":5331,"altCdsEndI":5385,"cigar":"55M"},{"altStartI":41209068,"altEndI":41209152,"ord":17,"altCdsStartI":5247,"altCdsEndI":5330,"cigar":"84M"},{"altStartI":41215349,"altEndI":41215390,"ord":16,"altCdsStartI":5206,"altCdsEndI":5246,"cigar":"41M"},{"altStartI":41215890,"altEndI":41215968,"ord":15,"altCdsStartI":5128,"altCdsEndI":5205,"cigar":"78M"},{"altStartI":41219624,"altEndI":41219712,"ord":14,"altCdsStartI":5040,"altCdsEndI":5127,"cigar":"88M"},{"altStartI":41222944,"altEndI":41223255,"ord":13,"altCdsStartI":4729,"altCdsEndI":5039,"cigar":"311M"},{"altStartI":41226347,"altEndI":41226538,"ord":12,"altCdsStartI":4538,"altCdsEndI":4728,"cigar":"191M"},{"altStartI":41228504,"altEndI":41228631,"ord":11,"altCdsStartI":4411,"altCdsEndI":4537,"cigar":"127M"},{"altStartI":41234420,"altEndI":41234592,"ord":10,"altCdsStartI":4239,"altCdsEndI":4410,"cigar":"172M"},{"altStartI":41242960,"altEndI":41243049,"ord":9,"altCdsStartI":4150,"altCdsEndI":4238,"cigar":"89M"},{"altStartI":41243451,"altEndI":41246877,"ord":8,"altCdsStartI":724,"altCdsEndI":4149,"cigar":"3426M"},{"altStartI":41247862,"altEndI":41247939,"ord":7,"altCdsStartI":647,"altCdsEndI":723,"cigar":"77M"},{"altStartI":41249260,"altEndI":41249306,"ord":6,"altCdsStartI":601,"altCdsEndI":646,"cigar":"46M"},{"altStartI":41251791,"altEndI":41251897,"ord":5,"altCdsStartI":495,"altCdsEndI":600,"cigar":"106M"},{"altStartI":41256138,"altEndI":41256278,"ord":4,"altCdsStartI":355,"altCdsEndI":494,"cigar":"140M"},{"altStartI":41256884,"altEndI":41256973,"ord":3,"altCdsStartI":266,"altCdsEndI":354,"cigar":"89M"},{"altStartI":41258472,"altEndI":41258550,"ord":2,"altCdsStartI":188,"altCdsEndI":265,"cigar":"78M"},{"altStartI":41276033,"altEndI":41276132,"ord":1,"altCdsStartI":89,"altCdsEndI":187,"cigar":"99M"},{"altStartI":41277293,"altEndI":41277381,"altCdsStartI":1,"altCdsEndI":88,"cigar":"88M"}]}]},{"id":"NM_007298.3","geneSymbol":"BRCA1","geneId":"HGNC:1100","biotype":"TRANSCRIPT_BIOTYPE_CODING","protein":"NP_009229.2","startCodon":19,"stopCodon":2299,"genomeAlignments":[{"genomeBuild":"GENOME_BUILD_GRCH37","contig":"NC_000017.10","cdsStart":41197694,"cdsEnd":41276113,"strand":"STRAND_MINUS","exons":[{"altStartI":41196311,"altEndI":41197819,"ord":21,"altCdsStartI":2175,"altCdsEndI":3682,"cigar":"1508M"},{"altStartI":41199659,"altEndI":41199720,"ord":20,"altCdsStartI":2114,"altCdsEndI":2174,"cigar":"61M"},{"altStartI":41201137,"altEndI":41201211,"ord":19,"altCdsStartI":2040,"altCdsEndI":2113,"cigar":"74M"},{"altStartI":41203079,"altEndI":41203134,"ord":18,"altCdsStartI":1985,"altCdsEndI":2039,"cigar":"55M"},{"altStartI":41209068,"altEndI":41209152,"ord":17,"altCdsStartI":1901,"altCdsEndI":1984,"cigar":"84M"},{"altStartI":41215349,"altEndI":41215390,"ord":16,"altCdsStartI":1860,"altCdsEndI":1900,"cigar":"41M"},{"altStartI":41215890,"altEndI":41215968,"ord":15,"altCdsStartI":1782,"altCdsEndI":1859,"cigar":"78M"},{"altStartI":41219624,"altEndI":41219712,"ord":14,"altCdsStartI":1694,"altCdsEndI":1781,"cigar":"88M"},{"altStartI":41222944,"altEndI":41223255,"ord":13,"altCdsStartI":1383,"altCdsEndI":1693,"cigar":"311M"},{"altStartI":41226347,"altEndI":41226538,"ord":12,"altCdsStartI":1192,"altCdsEndI":1382,"cigar":"191M"},{"altStartI":41228504,"altEndI":41228628,"ord":11,"altCdsStartI":1068,"altCdsEndI":1191,"cigar":"124M"},{"altStartI":41234420,"altEndI":41234592,"ord":10,"altCdsStartI":896,"altCdsEndI":1067,"cigar":"172M"},{"altStartI":41242960,"altEndI":41243049,"ord":9,"altCdsStartI":807,"altCdsEndI":895,"cigar":"89M"},{"altStartI":41246760,"altEndI":41246877,"ord":8,"altCdsStartI":690,"altCdsEndI":806,"cigar":"117M"},{"altStartI":41247862,"altEndI":41247939,"ord":7,"altCdsStartI":613,"altCdsEndI":689,"cigar":"77M"},{"altStartI":41249260,"altEndI":41249306,"ord":6,"altCdsStartI":567,"altCdsEndI":612,"cigar":"46M"},{"altStartI":41251791,"altEndI":41251897,"ord":5,"altCdsStartI":461,"altCdsEndI":566,"cigar":"106M"},{"altStartI":41256138,"altEndI":41256278,"ord":4,"altCdsStartI":321,"altCdsEndI":460,"cigar":"140M"},{"altStartI":41256884,"altEndI":41256973,"ord":3,"altCdsStartI":232,"altCdsEndI":320,"cigar":"89M"},{"altStartI":41258472,"altEndI":41258550,"ord":2,"altCdsStartI":154,"altCdsEndI":231,"cigar":"78M"},{"altStartI":41267742,"altEndI":41267796,"ord":1,"altCdsStartI":100,"altCdsEndI":153,"cigar":"54M"},{"altStartI":41276033,"altEndI":41276132,"altCdsStartI":1,"altCdsEndI":99,"cigar":"99M"}]}]},{"id":"NM_007299.4","geneSymbol":"BRCA1","geneId":"HGNC:1100","biotype":"TRANSCRIPT_BIOTYPE_CODING","protein":"NP_009230.2","startCodon":107,"stopCodon":2207,"genomeAlignments":[{"genomeBuild":"GENOME_BUILD_GRCH37","contig":"NC_000017.10","cdsStart":41197800,"cdsEnd":41276113,"strand":"STRAND_MINUS","exons":[{"altStartI":41196311,"altEndI":41197819,"ord":21,"altCdsStartI":2189,"altCdsEndI":3696,"cigar":"1508M"},{"altStartI":41199659,"altEndI":41199720,"ord":20,"altCdsStartI":2128,"altCdsEndI":2188,"cigar":"61M"},{"altStartI":41203079,"altEndI":41203134,"ord":19,"altCdsStartI":2073,"altCdsEndI":2127,"cigar":"55M"},{"altStartI":41209068,"altEndI":41209152,"ord":18,"altCdsStartI":1989,"altCdsEndI":2072,"cigar":"84M"},{"altStartI":41215349,"altEndI":41215390,"ord":17,"altCdsStartI":1948,"altCdsEndI":1988,"cigar":"41M"},{"altStartI":41215890,"altEndI":41215968,"ord":16,"altCdsStartI":1870,"altCdsEndI":1947,"cigar":"78M"},{"altStartI":41219624,"altEndI":41219712,"ord":15,"altCdsStartI":1782,"altCdsEndI":1869,"cigar":"88M"},{"altStartI":41222944,"altEndI":41223255,"ord":14,"altCdsStartI":1471,"altCdsEndI":1781,"cigar":"311M"},{"altStartI":41226347,"altEndI":41226538,"ord":13,"altCdsStartI":1280,"altCdsEndI":1470,"cigar":"191M"},{"altStartI":41228504,"altEndI":41228628,"ord":12,"altCdsStartI":1156,"altCdsEndI":1279,"cigar":"124M"},{"altStartI":41234420,"altEndI":41234592,"ord":11,"altCdsStartI":984,"altCdsEndI":1155,"cigar":"172M"},{"altStartI":41242960,"altEndI":41243049,"ord":10,"altCdsStartI":895,"altCdsEndI":983,"cigar":"89M"},{"altStartI":41246760,"altEndI":41246877,"ord":9,"altCdsStartI":778,"altCdsEndI":894,"cigar":"117M"},{"altStartI":41247862,"altEndI":41247939,"ord":8,"altCdsStartI":701,"altCdsEndI":777,"cigar":"77M"},{"altStartI":41249260,"altEndI":41249306,"ord":7,"altCdsStartI":655,"altCdsEndI":700,"cigar":"46M"},{"altStartI":41251791,"altEndI":41251897,"ord":6,"altCdsStartI":549,"altCdsEndI":654,"cigar":"106M"},{"altStartI":41256138,"altEndI":41256278,"ord":5,"altCdsStartI":409,"altCdsEndI":548,"cigar":"140M"},{"altStartI":41256884,"altEndI":41256973,"ord":4,"altCdsStartI":320,"altCdsEndI":408,"cigar":"89M"},{"altStartI":41258472,"altEndI":41258550,"ord":3,"altCdsStartI":242,"altCdsEndI":319,"cigar":"78M"},{"altStartI":41267742,"altEndI":41267796,"ord":2,"altCdsStartI":188,"altCdsEndI":241,"cigar":"54M"},{"altStartI":41276033,"altEndI":41276132,"ord":1,"altCdsStartI":89,"altCdsEndI":187,"cigar":"99M"},{"altStartI":41277293,"altEndI":41277381,"altCdsStartI":1,"altCdsEndI":88,"cigar":"88M"}]}]},{"id":"NM_007300.4","geneSymbol":"BRCA1","geneId":"HGNC:1100","biotype":"TRANSCRIPT_BIOTYPE_CODING","protein":"NP_009231.2","startCodon":113,"stopCodon":5768,"genomeAlignments":[{"genomeBuild":"GENOME_BUILD_GRCH37","contig":"NC_000017.10","cdsStart":41197694,"cdsEnd":41276113,"strand":"STRAND_MINUS","exons":[{"altStartI":41196311,"altEndI":41197819,"ord":23,"altCdsStartI":5644,"altCdsEndI":7151,"cigar":"1508M"},{"altStartI":41199659,"altEndI":41199720,"ord":22,"altCdsStartI":5583,"altCdsEndI":5643,"cigar":"61M"},{"altStartI":41201137,"altEndI":41201211,"ord":21,"altCdsStartI":5509,"altCdsEndI":5582,"cigar":"74M"},{"altStartI":41203079,"altEndI":41203134,"ord":20,"altCdsStartI":5454,"altCdsEndI":5508,"cigar":"55M"},{"altStartI":41209068,"altEndI":41209152,"ord":19,"altCdsStartI":5370,"altCdsEndI":5453,"cigar":"84M"},{"altStartI":41215349,"altEndI":41215390,"ord":18,"altCdsStartI":5329,"altCdsEndI":5369,"cigar":"41M"},{"altStartI":41215890,"altEndI":41215968,"ord":17,"altCdsStartI":5251,"altCdsEndI":5328,"cigar":"78M"},{"altStartI":41219624,"altEndI":41219712,"ord":16,"altCdsStartI":5163,"altCdsEndI":5250,"cigar":"88M"},{"altStartI":41222944,"altEndI":41223255,"ord":15,"altCdsStartI":4852,"altCdsEndI":5162,"cigar":"311M"},{"altStartI":41226347,"altEndI":41226538,"ord":14,"altCdsStartI":4661,"altCdsEndI":4851,"cigar":"191M"},{"altStartI":41228504,"altEndI":41228628,"ord":13,"altCdsStartI":4537,"altCdsEndI":4660,"cigar":"124M"},{"altStartI":41231350,"altEndI":41231416,"ord":12,"altCdsStartI":4471,"altCdsEndI":4536,"cigar":"66M"},{"altStartI":41234420,"altEndI":41234592,"ord":11,"altCdsStartI":4299,"altCdsEndI":4470,"cigar":"172M"},{"altStartI":41242960,"altEndI":41243049,"ord":10,"altCdsStartI":4210,"altCdsEndI":4298,"cigar":"89M"},{"altStartI":41243451,"altEndI":41246877,"ord":9,"altCdsStartI":784,"altCdsEndI":4209,"cigar":"3426M"},{"altStartI":41247862,"altEndI":41247939,"ord":8,"altCdsStartI":707,"altCdsEndI":783,"cigar":"77M"},{"altStartI":41249260,"altEndI":41249306,"ord":7,"altCdsStartI":661,"altCdsEndI":706,"cigar":"46M"},{"altStartI":41251791,"altEndI":41251897,"ord":6,"altCdsStartI":555,"altCdsEndI":660,"cigar":"106M"},{"altStartI":41256138,"altEndI":41256278,"ord":5,"altCdsStartI":415,"altCdsEndI":554,"cigar":"140M"},{"altStartI":41256884,"altEndI":41256973,"ord":4,"altCdsStartI":326,"altCdsEndI":414,"cigar":"89M"},{"altStartI":41258472,"altEndI":41258550,"ord":3,"altCdsStartI":248,"altCdsEndI":325,"cigar":"78M"},{"altStartI":41267742,"altEndI":41267796,"ord":2,"altCdsStartI":194,"altCdsEndI":247,"cigar":"54M"},{"altStartI":41276033,"altEndI":41276132,"ord":1,"altCdsStartI":95,"altCdsEndI":193,"cigar":"99M"},{"altStartI":41277287,"altEndI":41277381,"altCdsStartI":1,"altCdsEndI":94,"cigar":"94M"}]}]}]}"`; +exports[`MehariClient > properly returns gene transcripts 1`] = `"{"transcripts":[{"id":"NM_007294.4","geneSymbol":"BRCA1","geneId":"HGNC:1100","biotype":1,"tags":[3,5],"genomeAlignments":[{"genomeBuild":1,"contig":"NC_000017.10","strand":2,"exons":[{"altStartI":41196311,"altEndI":41197819,"ord":22,"cigar":"1508M","altCdsStartI":5581,"altCdsEndI":7088},{"altStartI":41199659,"altEndI":41199720,"ord":21,"cigar":"61M","altCdsStartI":5520,"altCdsEndI":5580},{"altStartI":41201137,"altEndI":41201211,"ord":20,"cigar":"74M","altCdsStartI":5446,"altCdsEndI":5519},{"altStartI":41203079,"altEndI":41203134,"ord":19,"cigar":"55M","altCdsStartI":5391,"altCdsEndI":5445},{"altStartI":41209068,"altEndI":41209152,"ord":18,"cigar":"84M","altCdsStartI":5307,"altCdsEndI":5390},{"altStartI":41215349,"altEndI":41215390,"ord":17,"cigar":"41M","altCdsStartI":5266,"altCdsEndI":5306},{"altStartI":41215890,"altEndI":41215968,"ord":16,"cigar":"78M","altCdsStartI":5188,"altCdsEndI":5265},{"altStartI":41219624,"altEndI":41219712,"ord":15,"cigar":"88M","altCdsStartI":5100,"altCdsEndI":5187},{"altStartI":41222944,"altEndI":41223255,"ord":14,"cigar":"311M","altCdsStartI":4789,"altCdsEndI":5099},{"altStartI":41226347,"altEndI":41226538,"ord":13,"cigar":"191M","altCdsStartI":4598,"altCdsEndI":4788},{"altStartI":41228504,"altEndI":41228631,"ord":12,"cigar":"127M","altCdsStartI":4471,"altCdsEndI":4597},{"altStartI":41234420,"altEndI":41234592,"ord":11,"cigar":"172M","altCdsStartI":4299,"altCdsEndI":4470},{"altStartI":41242960,"altEndI":41243049,"ord":10,"cigar":"89M","altCdsStartI":4210,"altCdsEndI":4298},{"altStartI":41243451,"altEndI":41246877,"ord":9,"cigar":"3426M","altCdsStartI":784,"altCdsEndI":4209},{"altStartI":41247862,"altEndI":41247939,"ord":8,"cigar":"77M","altCdsStartI":707,"altCdsEndI":783},{"altStartI":41249260,"altEndI":41249306,"ord":7,"cigar":"46M","altCdsStartI":661,"altCdsEndI":706},{"altStartI":41251791,"altEndI":41251897,"ord":6,"cigar":"106M","altCdsStartI":555,"altCdsEndI":660},{"altStartI":41256138,"altEndI":41256278,"ord":5,"cigar":"140M","altCdsStartI":415,"altCdsEndI":554},{"altStartI":41256884,"altEndI":41256973,"ord":4,"cigar":"89M","altCdsStartI":326,"altCdsEndI":414},{"altStartI":41258472,"altEndI":41258550,"ord":3,"cigar":"78M","altCdsStartI":248,"altCdsEndI":325},{"altStartI":41267742,"altEndI":41267796,"ord":2,"cigar":"54M","altCdsStartI":194,"altCdsEndI":247},{"altStartI":41276033,"altEndI":41276132,"ord":1,"cigar":"99M","altCdsStartI":95,"altCdsEndI":193},{"altStartI":41277287,"altEndI":41277381,"ord":0,"cigar":"94M","altCdsStartI":1,"altCdsEndI":94}],"cdsStart":41197694,"cdsEnd":41276113}],"protein":"NP_009225.1","startCodon":113,"stopCodon":5705},{"id":"NM_007297.4","geneSymbol":"BRCA1","geneId":"HGNC:1100","biotype":1,"tags":[],"genomeAlignments":[{"genomeBuild":1,"contig":"NC_000017.10","strand":2,"exons":[{"altStartI":41196311,"altEndI":41197819,"ord":21,"cigar":"1508M","altCdsStartI":5521,"altCdsEndI":7028},{"altStartI":41199659,"altEndI":41199720,"ord":20,"cigar":"61M","altCdsStartI":5460,"altCdsEndI":5520},{"altStartI":41201137,"altEndI":41201211,"ord":19,"cigar":"74M","altCdsStartI":5386,"altCdsEndI":5459},{"altStartI":41203079,"altEndI":41203134,"ord":18,"cigar":"55M","altCdsStartI":5331,"altCdsEndI":5385},{"altStartI":41209068,"altEndI":41209152,"ord":17,"cigar":"84M","altCdsStartI":5247,"altCdsEndI":5330},{"altStartI":41215349,"altEndI":41215390,"ord":16,"cigar":"41M","altCdsStartI":5206,"altCdsEndI":5246},{"altStartI":41215890,"altEndI":41215968,"ord":15,"cigar":"78M","altCdsStartI":5128,"altCdsEndI":5205},{"altStartI":41219624,"altEndI":41219712,"ord":14,"cigar":"88M","altCdsStartI":5040,"altCdsEndI":5127},{"altStartI":41222944,"altEndI":41223255,"ord":13,"cigar":"311M","altCdsStartI":4729,"altCdsEndI":5039},{"altStartI":41226347,"altEndI":41226538,"ord":12,"cigar":"191M","altCdsStartI":4538,"altCdsEndI":4728},{"altStartI":41228504,"altEndI":41228631,"ord":11,"cigar":"127M","altCdsStartI":4411,"altCdsEndI":4537},{"altStartI":41234420,"altEndI":41234592,"ord":10,"cigar":"172M","altCdsStartI":4239,"altCdsEndI":4410},{"altStartI":41242960,"altEndI":41243049,"ord":9,"cigar":"89M","altCdsStartI":4150,"altCdsEndI":4238},{"altStartI":41243451,"altEndI":41246877,"ord":8,"cigar":"3426M","altCdsStartI":724,"altCdsEndI":4149},{"altStartI":41247862,"altEndI":41247939,"ord":7,"cigar":"77M","altCdsStartI":647,"altCdsEndI":723},{"altStartI":41249260,"altEndI":41249306,"ord":6,"cigar":"46M","altCdsStartI":601,"altCdsEndI":646},{"altStartI":41251791,"altEndI":41251897,"ord":5,"cigar":"106M","altCdsStartI":495,"altCdsEndI":600},{"altStartI":41256138,"altEndI":41256278,"ord":4,"cigar":"140M","altCdsStartI":355,"altCdsEndI":494},{"altStartI":41256884,"altEndI":41256973,"ord":3,"cigar":"89M","altCdsStartI":266,"altCdsEndI":354},{"altStartI":41258472,"altEndI":41258550,"ord":2,"cigar":"78M","altCdsStartI":188,"altCdsEndI":265},{"altStartI":41276033,"altEndI":41276132,"ord":1,"cigar":"99M","altCdsStartI":89,"altCdsEndI":187},{"altStartI":41277293,"altEndI":41277381,"ord":0,"cigar":"88M","altCdsStartI":1,"altCdsEndI":88}],"cdsStart":41197694,"cdsEnd":41258543}],"protein":"NP_009228.2","startCodon":194,"stopCodon":5645},{"id":"NM_007298.3","geneSymbol":"BRCA1","geneId":"HGNC:1100","biotype":1,"tags":[],"genomeAlignments":[{"genomeBuild":1,"contig":"NC_000017.10","strand":2,"exons":[{"altStartI":41196311,"altEndI":41197819,"ord":21,"cigar":"1508M","altCdsStartI":2175,"altCdsEndI":3682},{"altStartI":41199659,"altEndI":41199720,"ord":20,"cigar":"61M","altCdsStartI":2114,"altCdsEndI":2174},{"altStartI":41201137,"altEndI":41201211,"ord":19,"cigar":"74M","altCdsStartI":2040,"altCdsEndI":2113},{"altStartI":41203079,"altEndI":41203134,"ord":18,"cigar":"55M","altCdsStartI":1985,"altCdsEndI":2039},{"altStartI":41209068,"altEndI":41209152,"ord":17,"cigar":"84M","altCdsStartI":1901,"altCdsEndI":1984},{"altStartI":41215349,"altEndI":41215390,"ord":16,"cigar":"41M","altCdsStartI":1860,"altCdsEndI":1900},{"altStartI":41215890,"altEndI":41215968,"ord":15,"cigar":"78M","altCdsStartI":1782,"altCdsEndI":1859},{"altStartI":41219624,"altEndI":41219712,"ord":14,"cigar":"88M","altCdsStartI":1694,"altCdsEndI":1781},{"altStartI":41222944,"altEndI":41223255,"ord":13,"cigar":"311M","altCdsStartI":1383,"altCdsEndI":1693},{"altStartI":41226347,"altEndI":41226538,"ord":12,"cigar":"191M","altCdsStartI":1192,"altCdsEndI":1382},{"altStartI":41228504,"altEndI":41228628,"ord":11,"cigar":"124M","altCdsStartI":1068,"altCdsEndI":1191},{"altStartI":41234420,"altEndI":41234592,"ord":10,"cigar":"172M","altCdsStartI":896,"altCdsEndI":1067},{"altStartI":41242960,"altEndI":41243049,"ord":9,"cigar":"89M","altCdsStartI":807,"altCdsEndI":895},{"altStartI":41246760,"altEndI":41246877,"ord":8,"cigar":"117M","altCdsStartI":690,"altCdsEndI":806},{"altStartI":41247862,"altEndI":41247939,"ord":7,"cigar":"77M","altCdsStartI":613,"altCdsEndI":689},{"altStartI":41249260,"altEndI":41249306,"ord":6,"cigar":"46M","altCdsStartI":567,"altCdsEndI":612},{"altStartI":41251791,"altEndI":41251897,"ord":5,"cigar":"106M","altCdsStartI":461,"altCdsEndI":566},{"altStartI":41256138,"altEndI":41256278,"ord":4,"cigar":"140M","altCdsStartI":321,"altCdsEndI":460},{"altStartI":41256884,"altEndI":41256973,"ord":3,"cigar":"89M","altCdsStartI":232,"altCdsEndI":320},{"altStartI":41258472,"altEndI":41258550,"ord":2,"cigar":"78M","altCdsStartI":154,"altCdsEndI":231},{"altStartI":41267742,"altEndI":41267796,"ord":1,"cigar":"54M","altCdsStartI":100,"altCdsEndI":153},{"altStartI":41276033,"altEndI":41276132,"ord":0,"cigar":"99M","altCdsStartI":1,"altCdsEndI":99}],"cdsStart":41197694,"cdsEnd":41276113}],"protein":"NP_009229.2","startCodon":19,"stopCodon":2299},{"id":"NM_007299.4","geneSymbol":"BRCA1","geneId":"HGNC:1100","biotype":1,"tags":[],"genomeAlignments":[{"genomeBuild":1,"contig":"NC_000017.10","strand":2,"exons":[{"altStartI":41196311,"altEndI":41197819,"ord":21,"cigar":"1508M","altCdsStartI":2189,"altCdsEndI":3696},{"altStartI":41199659,"altEndI":41199720,"ord":20,"cigar":"61M","altCdsStartI":2128,"altCdsEndI":2188},{"altStartI":41203079,"altEndI":41203134,"ord":19,"cigar":"55M","altCdsStartI":2073,"altCdsEndI":2127},{"altStartI":41209068,"altEndI":41209152,"ord":18,"cigar":"84M","altCdsStartI":1989,"altCdsEndI":2072},{"altStartI":41215349,"altEndI":41215390,"ord":17,"cigar":"41M","altCdsStartI":1948,"altCdsEndI":1988},{"altStartI":41215890,"altEndI":41215968,"ord":16,"cigar":"78M","altCdsStartI":1870,"altCdsEndI":1947},{"altStartI":41219624,"altEndI":41219712,"ord":15,"cigar":"88M","altCdsStartI":1782,"altCdsEndI":1869},{"altStartI":41222944,"altEndI":41223255,"ord":14,"cigar":"311M","altCdsStartI":1471,"altCdsEndI":1781},{"altStartI":41226347,"altEndI":41226538,"ord":13,"cigar":"191M","altCdsStartI":1280,"altCdsEndI":1470},{"altStartI":41228504,"altEndI":41228628,"ord":12,"cigar":"124M","altCdsStartI":1156,"altCdsEndI":1279},{"altStartI":41234420,"altEndI":41234592,"ord":11,"cigar":"172M","altCdsStartI":984,"altCdsEndI":1155},{"altStartI":41242960,"altEndI":41243049,"ord":10,"cigar":"89M","altCdsStartI":895,"altCdsEndI":983},{"altStartI":41246760,"altEndI":41246877,"ord":9,"cigar":"117M","altCdsStartI":778,"altCdsEndI":894},{"altStartI":41247862,"altEndI":41247939,"ord":8,"cigar":"77M","altCdsStartI":701,"altCdsEndI":777},{"altStartI":41249260,"altEndI":41249306,"ord":7,"cigar":"46M","altCdsStartI":655,"altCdsEndI":700},{"altStartI":41251791,"altEndI":41251897,"ord":6,"cigar":"106M","altCdsStartI":549,"altCdsEndI":654},{"altStartI":41256138,"altEndI":41256278,"ord":5,"cigar":"140M","altCdsStartI":409,"altCdsEndI":548},{"altStartI":41256884,"altEndI":41256973,"ord":4,"cigar":"89M","altCdsStartI":320,"altCdsEndI":408},{"altStartI":41258472,"altEndI":41258550,"ord":3,"cigar":"78M","altCdsStartI":242,"altCdsEndI":319},{"altStartI":41267742,"altEndI":41267796,"ord":2,"cigar":"54M","altCdsStartI":188,"altCdsEndI":241},{"altStartI":41276033,"altEndI":41276132,"ord":1,"cigar":"99M","altCdsStartI":89,"altCdsEndI":187},{"altStartI":41277293,"altEndI":41277381,"ord":0,"cigar":"88M","altCdsStartI":1,"altCdsEndI":88}],"cdsStart":41197800,"cdsEnd":41276113}],"protein":"NP_009230.2","startCodon":107,"stopCodon":2207},{"id":"NM_007300.4","geneSymbol":"BRCA1","geneId":"HGNC:1100","biotype":1,"tags":[],"genomeAlignments":[{"genomeBuild":1,"contig":"NC_000017.10","strand":2,"exons":[{"altStartI":41196311,"altEndI":41197819,"ord":23,"cigar":"1508M","altCdsStartI":5644,"altCdsEndI":7151},{"altStartI":41199659,"altEndI":41199720,"ord":22,"cigar":"61M","altCdsStartI":5583,"altCdsEndI":5643},{"altStartI":41201137,"altEndI":41201211,"ord":21,"cigar":"74M","altCdsStartI":5509,"altCdsEndI":5582},{"altStartI":41203079,"altEndI":41203134,"ord":20,"cigar":"55M","altCdsStartI":5454,"altCdsEndI":5508},{"altStartI":41209068,"altEndI":41209152,"ord":19,"cigar":"84M","altCdsStartI":5370,"altCdsEndI":5453},{"altStartI":41215349,"altEndI":41215390,"ord":18,"cigar":"41M","altCdsStartI":5329,"altCdsEndI":5369},{"altStartI":41215890,"altEndI":41215968,"ord":17,"cigar":"78M","altCdsStartI":5251,"altCdsEndI":5328},{"altStartI":41219624,"altEndI":41219712,"ord":16,"cigar":"88M","altCdsStartI":5163,"altCdsEndI":5250},{"altStartI":41222944,"altEndI":41223255,"ord":15,"cigar":"311M","altCdsStartI":4852,"altCdsEndI":5162},{"altStartI":41226347,"altEndI":41226538,"ord":14,"cigar":"191M","altCdsStartI":4661,"altCdsEndI":4851},{"altStartI":41228504,"altEndI":41228628,"ord":13,"cigar":"124M","altCdsStartI":4537,"altCdsEndI":4660},{"altStartI":41231350,"altEndI":41231416,"ord":12,"cigar":"66M","altCdsStartI":4471,"altCdsEndI":4536},{"altStartI":41234420,"altEndI":41234592,"ord":11,"cigar":"172M","altCdsStartI":4299,"altCdsEndI":4470},{"altStartI":41242960,"altEndI":41243049,"ord":10,"cigar":"89M","altCdsStartI":4210,"altCdsEndI":4298},{"altStartI":41243451,"altEndI":41246877,"ord":9,"cigar":"3426M","altCdsStartI":784,"altCdsEndI":4209},{"altStartI":41247862,"altEndI":41247939,"ord":8,"cigar":"77M","altCdsStartI":707,"altCdsEndI":783},{"altStartI":41249260,"altEndI":41249306,"ord":7,"cigar":"46M","altCdsStartI":661,"altCdsEndI":706},{"altStartI":41251791,"altEndI":41251897,"ord":6,"cigar":"106M","altCdsStartI":555,"altCdsEndI":660},{"altStartI":41256138,"altEndI":41256278,"ord":5,"cigar":"140M","altCdsStartI":415,"altCdsEndI":554},{"altStartI":41256884,"altEndI":41256973,"ord":4,"cigar":"89M","altCdsStartI":326,"altCdsEndI":414},{"altStartI":41258472,"altEndI":41258550,"ord":3,"cigar":"78M","altCdsStartI":248,"altCdsEndI":325},{"altStartI":41267742,"altEndI":41267796,"ord":2,"cigar":"54M","altCdsStartI":194,"altCdsEndI":247},{"altStartI":41276033,"altEndI":41276132,"ord":1,"cigar":"99M","altCdsStartI":95,"altCdsEndI":193},{"altStartI":41277287,"altEndI":41277381,"ord":0,"cigar":"94M","altCdsStartI":1,"altCdsEndI":94}],"cdsStart":41197694,"cdsEnd":41276113}],"protein":"NP_009231.2","startCodon":113,"stopCodon":5768}]}"`; exports[`MehariClient/seqvar > fails to fetch variant info with wrong variant 1`] = `"{"version":{"txDb":"0.5.0","mehari":"0.7.0"},"query":{"genomeRelease":"grch37","chromosome":"chr17","position":41197751,"reference":"G","alternative":"T","hgncId":null},"result":[{"consequences":["MissenseVariant"],"putativeImpact":"Moderate","geneSymbol":"BRCA1","geneId":"HGNC:1100","featureType":{"SoTerm":{"term":"Transcript"}},"featureId":"NM_007294.4","featureBiotype":"Coding","featureTag":["ManeSelect"],"rank":{"total":23},"hgvsT":"c.5536C>A","hgvsP":"p.Q1846K","txPos":{"ord":5649,"total":7088},"cdsPos":{"ord":5536,"total":5592},"proteinPos":{"ord":1846,"total":1864},"distance":-1440},{"consequences":["MissenseVariant"],"putativeImpact":"Moderate","geneSymbol":"BRCA1","geneId":"HGNC:1100","featureType":{"SoTerm":{"term":"Transcript"}},"featureId":"NM_007297.4","featureBiotype":"Coding","rank":{"total":22},"hgvsT":"c.5395C>A","hgvsP":"p.Q1799K","txPos":{"ord":5589,"total":7028},"cdsPos":{"ord":5395,"total":5451},"proteinPos":{"ord":1799,"total":1817},"distance":-1440},{"consequences":["MissenseVariant"],"putativeImpact":"Moderate","geneSymbol":"BRCA1","geneId":"HGNC:1100","featureType":{"SoTerm":{"term":"Transcript"}},"featureId":"NM_007298.3","featureBiotype":"Coding","rank":{"total":22},"hgvsT":"c.2224C>A","hgvsP":"p.Q742K","txPos":{"ord":2243,"total":3682},"cdsPos":{"ord":2224,"total":2280},"proteinPos":{"ord":742,"total":760},"distance":-1440},{"consequences":["MissenseVariant"],"putativeImpact":"Moderate","geneSymbol":"BRCA1","geneId":"HGNC:1100","featureType":{"SoTerm":{"term":"Transcript"}},"featureId":"NM_007300.4","featureBiotype":"Coding","rank":{"total":24},"hgvsT":"c.5599C>A","hgvsP":"p.Q1867K","txPos":{"ord":5712,"total":7151},"cdsPos":{"ord":5599,"total":5655},"proteinPos":{"ord":1867,"total":1885},"distance":-1440},{"consequences":["ThreePrimeUtrVariant"],"putativeImpact":"Modifier","geneSymbol":"BRCA1","geneId":"HGNC:1100","featureType":{"SoTerm":{"term":"Transcript"}},"featureId":"NM_007299.4","featureBiotype":"Coding","rank":{"total":22},"hgvsT":"c.*50C>A","hgvsP":"p.?","txPos":{"ord":2257,"total":3696},"cdsPos":{"ord":50,"total":2100},"distance":-1440}]}"`; diff --git a/src/api/mehari/client.spec.ts b/src/api/mehari/client.spec.ts index c9c80cc..d33cd7b 100644 --- a/src/api/mehari/client.spec.ts +++ b/src/api/mehari/client.spec.ts @@ -1,10 +1,11 @@ import fs from 'fs' import path from 'path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import createFetchMock from 'vitest-fetch-mock' import { SeqvarImpl } from '../../lib/genomicVars' import { LinearStrucvarImpl } from '../../lib/genomicVars' +import { setupUrlConfigForTesting, urlConfig } from '../../lib/urlConfig' import { GenomeBuild } from '../../pbs/mehari/txs' import { MehariClient } from './client' @@ -35,6 +36,38 @@ const strucvarCsqResponseBrca1 = JSON.parse( /** Initialize mock for `fetch()`. */ const fetchMocker = createFetchMock(vi) +describe.concurrent('MehariClient.construct()', () => { + afterEach(() => { + setupUrlConfigForTesting() + }) + + it('constructs correctly with default base URL', () => { + // act: + const client = new MehariClient() + + // assert: + expect(client).toBeDefined() + }) + + it('constructs correctly with custom base URL', () => { + // act: + const client = new MehariClient('http://localhost:8080') + + // assert: + expect(client).toBeDefined() + }) + + it('throws error if no base URL is configured', () => { + // arrange: + urlConfig.baseUrlMehari = undefined + + // (guarded) + expect(() => new MehariClient(undefined)).toThrow( + 'Configuration error: API base URL not configured' + ) + }) +}) + describe.concurrent('MehariClient', () => { beforeEach(() => { fetchMocker.enableMocks() diff --git a/src/api/mehari/client.ts b/src/api/mehari/client.ts index 6c8af1a..21c5252 100644 --- a/src/api/mehari/client.ts +++ b/src/api/mehari/client.ts @@ -2,6 +2,7 @@ import type { Seqvar, Strucvar } from '../../lib/genomicVars' import { urlConfig } from '../../lib/urlConfig' import { GeneTranscriptsResponse } from '../../pbs/mehari/server' import { GenomeBuild } from '../../pbs/mehari/txs' +import { ConfigError, InvalidResponseContent, StatusCodeNotOk } from '../common' import { SeqvarResult, StrucvarResult } from './types' /** @@ -14,14 +15,14 @@ export class MehariClient { * @param apiBaseUrl * API base to the backend, excluding trailing `/`. * The default is declared in '@/lib/urlConfig`. - * @throws Error if the API base URL is not configured. + * @throws ConfigError if the API base URL is not configured. */ constructor(apiBaseUrl?: string) { if (apiBaseUrl !== undefined || urlConfig.baseUrlMehari !== undefined) { // @ts-ignore this.apiBaseUrl = apiBaseUrl ?? urlConfig.baseUrlMehari } else { - throw new Error('Configuration error: API base URL not configured') + throw new ConfigError('Configuration error: API base URL not configured') } } @@ -30,8 +31,9 @@ export class MehariClient { * * @param seqvar Sequence variant to retrieve consequences for. * @param hgncId HGNC ID of gene to restrict results to. - * @returns The response from the API. - * @throws Error if the API request fails. + * @returns Promise with the response from the API. + * @throws StatusCodeNotOk if the API request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async retrieveSeqvarsCsq(seqvar: Seqvar, hgncId?: string): Promise { const { genomeBuild, chrom, pos, del, ins } = seqvar @@ -45,18 +47,27 @@ export class MehariClient { method: 'GET' }) if (!response.ok) { - throw new Error(`Failed to fetch sequence variant consequences: ${response.statusText}`) + throw new StatusCodeNotOk( + `Failed to fetch sequence variant consequences: ${response.statusText}` + ) + } + try { + const responseJson = await response.json() + return SeqvarResult.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent( + `Failed to parse sequence variant consequences response: ${e}` + ) } - const responseJson = await response.json() - return SeqvarResult.fromJson(responseJson) } /** * Retrieve consequences for structural variants. * * @param strucvar Structural variant to retrieve consequences for. - * @returns The response from the API. - * @throws Error if the API request fails. + * @returns Promise with the response from the API. + * @throws StatusCodeNotOk if the API request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async retrieveStrucvarsCsq(strucvar: Strucvar): Promise { let url: string @@ -76,10 +87,18 @@ export class MehariClient { method: 'GET' }) if (!response.ok) { - throw new Error(`Failed to fetch structural variant consequences: ${response.statusText}`) + throw new StatusCodeNotOk( + `Failed to fetch structural variant consequences: ${response.statusText}` + ) + } + try { + const responseJson = await response.json() + return StrucvarResult.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent( + `Failed to parse structural variant consequences response: ${e}` + ) } - const responseJson = await response.json() - return StrucvarResult.fromJson(responseJson) } /** @@ -89,7 +108,9 @@ export class MehariClient { * @param genomeBuild Genome build to restrict results to. * @param pageSize Number of results to return per page. * @param nextPageToken Token to retrieve the next page of results. - * @returns The response from the API. + * @returns Promise with the response from the API. + * @throws StatusCodeNotOk if the API request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async retrieveGeneTranscripts( hgncId: string, @@ -107,8 +128,13 @@ export class MehariClient { method: 'GET' }) if (!response.ok) { - throw new Error(`Failed to fetch transcripts: ${response.statusText}`) + throw new StatusCodeNotOk(`Failed to fetch transcripts: ${response.statusText}`) + } + try { + const responseJson = await response.json() + return GeneTranscriptsResponse.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`Failed to parse transcript response: ${e}`) } - return await response.json() } } diff --git a/src/api/pubtator/client.ts b/src/api/pubtator/client.ts index de253c0..5899645 100644 --- a/src/api/pubtator/client.ts +++ b/src/api/pubtator/client.ts @@ -1,4 +1,5 @@ import { urlConfig } from '../../lib/urlConfig' +import { ConfigError, InvalidResponseContent, StatusCodeNotOk } from '../common' import type { SearchResult } from './types' /** @@ -12,13 +13,14 @@ export class PubtatorClient { * @param apiBaseUrl * API base to the backend, excluding trailing `/`. * The default is declared in '@/lib/urlConfig`. + * @throws ConfigError if the API base URL is not configured. */ constructor(apiBaseUrl?: string) { if (apiBaseUrl !== undefined || urlConfig.baseUrlPubtator !== undefined) { // @ts-ignore this.apiBaseUrl = apiBaseUrl ?? urlConfig.baseUrlPubtator } else { - throw new Error('Configuration error: API base URL not configured') + throw new ConfigError('Configuration error: API base URL not configured') } } @@ -27,7 +29,8 @@ export class PubtatorClient { * * @param hgncSymbol HGNC symbol to search for. * @returns Promise for the search results. - * @throws Error if the search fails. + * @throws StatusCodeNotOk if the API request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async performSearch(hgncSymbol: string): Promise<{ [key: string]: SearchResult }> { const url = `${this.apiBaseUrl}/search/?text=@GENE_${hgncSymbol}` @@ -35,7 +38,7 @@ export class PubtatorClient { method: 'GET' }) if (!searchRes.ok) { - throw new Error(`Error running PubTator 3 search: ${searchRes.statusText}`) + throw new StatusCodeNotOk(`Error running PubTator 3 search: ${searchRes.statusText}`) } const searchData = await searchRes.json() @@ -45,7 +48,7 @@ export class PubtatorClient { `${this.apiBaseUrl}/publications/export/biocjson` + `?pmids=${pmids.join(',')}` ) if (!exportRes.ok) { - throw new Error(`Error running PubTator 3 export: ${exportRes.statusText}`) + throw new StatusCodeNotOk(`Error running PubTator 3 export: ${exportRes.statusText}`) } const exportDataText = await exportRes.text() const exportDataLines = exportDataText.split(/\n/) @@ -59,9 +62,13 @@ export class PubtatorClient { } } for (const exportDataLine of exportDataLines) { - if (exportDataLine) { - const exportDataRecord = JSON.parse(exportDataLine) - searchResults[exportDataRecord.pmid].abstract = exportDataRecord + try { + if (exportDataLine) { + const exportDataRecord = JSON.parse(exportDataLine) + searchResults[exportDataRecord.pmid].abstract = exportDataRecord + } + } catch (e) { + throw new InvalidResponseContent(`failed to parse PubTator 3 export: ${e}`) } } diff --git a/src/api/variantValidator/client.spec.ts b/src/api/variantValidator/client.spec.ts index fd3f2c6..b487361 100644 --- a/src/api/variantValidator/client.spec.ts +++ b/src/api/variantValidator/client.spec.ts @@ -1,9 +1,10 @@ import fs from 'fs' import path from 'path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import createFetchMock from 'vitest-fetch-mock' import { SeqvarImpl } from '../../lib/genomicVars' +import { setupUrlConfigForTesting, urlConfig } from '../../lib/urlConfig' import { VariantValidatorClient } from './client' /** Fixture Seqvar */ @@ -17,6 +18,38 @@ const responseManeBrca1Json = JSON.parse( /** Initialize mock for `fetch()`. */ const fetchMocker = createFetchMock(vi) +describe.concurrent('VariantValidatorClient.construct()', () => { + afterEach(() => { + setupUrlConfigForTesting() + }) + + it('constructs correctly with default base URL', () => { + // act: + const client = new VariantValidatorClient() + + // assert: + expect(client).toBeDefined() + }) + + it('constructs correctly with custom base URL', () => { + // act: + const client = new VariantValidatorClient('http://localhost:8080') + + // assert: + expect(client).toBeDefined() + }) + + it('throws error if no base URL is configured', () => { + // arrange: + urlConfig.baseUrlVariantValidator = undefined + + // (guarded) + expect(() => new VariantValidatorClient(undefined)).toThrow( + 'Configuration error: API base URL not configured' + ) + }) +}) + describe.concurrent('VariantValidator', () => { beforeEach(() => { fetchMocker.enableMocks() diff --git a/src/api/variantValidator/client.ts b/src/api/variantValidator/client.ts index 84e9654..8b4ca95 100644 --- a/src/api/variantValidator/client.ts +++ b/src/api/variantValidator/client.ts @@ -1,5 +1,6 @@ import type { Seqvar } from '../../lib/genomicVars' import { urlConfig } from '../../lib/urlConfig' +import { ConfigError, InvalidResponseContent, StatusCodeNotOk } from '../common' import { Response as VariantValidatorResponse } from './types' /** @@ -12,14 +13,14 @@ export class VariantValidatorClient { * @param apiBaseUrl * API base to the backend, excluding trailing `/`. * The default is declared in '@/lib/urlConfig`. - * @throws Error if the API base URL is not configured. + * @throws ConfigError if the API base URL is not configured. */ constructor(apiBaseUrl?: string) { if (apiBaseUrl !== undefined || urlConfig.baseUrlVariantValidator !== undefined) { // @ts-ignore this.apiBaseUrl = apiBaseUrl ?? urlConfig.baseUrlVariantValidator } else { - throw new Error('Configuration error: API base URL not configured') + throw new ConfigError('Configuration error: API base URL not configured') } } @@ -28,7 +29,8 @@ export class VariantValidatorClient { * * @param seqvar The `Seqvar` object to be validated. * @returns The response from the API. - * @throws Error if the API call fails. + * @throws StatusCodeNotOk if the API request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async fetchVvResults(seqvar: Seqvar): Promise { const { genomeBuild, chrom, pos, del, ins } = seqvar @@ -39,9 +41,13 @@ export class VariantValidatorClient { const response = await fetch(url, { method: 'GET' }) if (!response.ok) { - throw new Error(`Failed to fetch ACMG rating for ${seqvar.userRepr}`) + throw new StatusCodeNotOk(`Failed to fetch ACMG rating for ${seqvar.userRepr}`) + } + try { + const responseJson = await response.json() + return VariantValidatorResponse.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`Invalid response content from VariantValidator: ${e}`) } - const responseJson = await response.json() - return VariantValidatorResponse.fromJson(responseJson) } } diff --git a/src/api/viguno/client.spec.ts b/src/api/viguno/client.spec.ts index 030dd09..9f31bb5 100644 --- a/src/api/viguno/client.spec.ts +++ b/src/api/viguno/client.spec.ts @@ -1,8 +1,9 @@ import fs from 'fs' import path from 'path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import createFetchMock from 'vitest-fetch-mock' +import { setupUrlConfigForTesting, urlConfig } from '../../lib/urlConfig' import { VigunoClient } from './client' import { HpoOmimsResult, HpoTermResult } from './types' @@ -29,6 +30,38 @@ const responseResolveHpoTermByIdFoobar = JSON.parse( fs.readFileSync(path.resolve(__dirname, './fixture.resolveHpoTermByName.foobar.json'), 'utf8') ) +describe.concurrent('VigunoClient.construct()', () => { + afterEach(() => { + setupUrlConfigForTesting() + }) + + it('constructs correctly with default base URL', () => { + // act: + const client = new VigunoClient() + + // assert: + expect(client).toBeDefined() + }) + + it('constructs correctly with custom base URL', () => { + // act: + const client = new VigunoClient('http://localhost:8080') + + // assert: + expect(client).toBeDefined() + }) + + it('throws error if no base URL is configured', () => { + // arrange: + urlConfig.baseUrlViguno = undefined + + // (guarded) + expect(() => new VigunoClient(undefined)).toThrow( + 'Configuration error: API base URL not configured' + ) + }) +}) + describe.concurrent('Viguno Client', () => { beforeEach(() => { fetchMocker.enableMocks() diff --git a/src/api/viguno/client.ts b/src/api/viguno/client.ts index 72e43ca..5e411a4 100644 --- a/src/api/viguno/client.ts +++ b/src/api/viguno/client.ts @@ -1,10 +1,13 @@ import { urlConfig } from '../../lib/urlConfig' +import { ConfigError, InvalidResponseContent, StatusCodeNotOk } from '../common' import { HpoGenesResult, HpoOmimsResult, HpoTermResult } from './types' export class VigunoClient { private apiBaseUrl: string /** + * Construct Viguno client. + * * @param apiBaseUrl * API base to the backend, excluding trailing `/`. * The default is declared in '@/lib/urlConfig`. @@ -15,10 +18,18 @@ export class VigunoClient { // @ts-ignore this.apiBaseUrl = apiBaseUrl ?? urlConfig.baseUrlViguno } else { - throw new Error('Configuration error: API base URL not configured') + throw new ConfigError('Configuration error: API base URL not configured') } } + /** + * Resolve OMIM term by its ID. + * + * @param id OMIM ID to resolve. + * @returns Promise with the response from the server. + * @throws StatusCodeNotOk if the request fails. + * @throws InvalidResponseContent if the response is not valid JSON. + */ async resolveOmimTermById(id: string): Promise { const url = `${this.apiBaseUrl}/hpo/omims?omim_id=${id}` const response = await fetch(url, { @@ -27,13 +38,26 @@ export class VigunoClient { if (!response.ok) { const errorBody = await response.json() - throw new Error(errorBody.msg || response.statusText) + throw new StatusCodeNotOk(errorBody.msg || response.statusText) } - const responseJson = await response.json() - return HpoOmimsResult.fromJson(responseJson) + try { + const responseJson = await response.json() + return HpoOmimsResult.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse OMIM response: ${e}`) + } } + /** + * Query OMIM term by name. + * + * @param query The query string. + * @param matchType How to match the query. + * @returns Promise with the response from the server. + * @throws StatusCodeNotOk if the request fails. + * @throws InvalidResponseContent if the response is not valid JSON. + */ async queryOmimTermsByName( query: string, matchType: string = 'contains' @@ -45,13 +69,25 @@ export class VigunoClient { if (!response.ok) { const errorBody = await response.json() - throw new Error(errorBody.msg || response.statusText) + throw new StatusCodeNotOk(errorBody.msg || response.statusText) } - const responseJson = await response.json() - return HpoOmimsResult.fromJson(responseJson) + try { + const responseJson = await response.json() + return HpoOmimsResult.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse OMIM response: ${e}`) + } } + /** + * Resolve HPO term by ID. + * + * @param id HPO ID to resolve. + * @returns Promise with the resolution result. + * @throws StatusCodeNotOk if the request fails. + * @throws InvalidResponseContent if the response is not valid JSON. + */ async resolveHpoTermById(id: string): Promise { const url = `${this.apiBaseUrl}/hpo/terms?term_id=${id}` const response = await fetch(url, { @@ -60,13 +96,25 @@ export class VigunoClient { if (!response.ok) { const errorBody = await response.json() - throw new Error(errorBody.msg || response.statusText) + throw new StatusCodeNotOk(errorBody.msg || response.statusText) } - const responseJson = await response.json() - return HpoTermResult.fromJson(responseJson) + try { + const responseJson = await response.json() + return HpoTermResult.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse HPO response: ${e}`) + } } + /** + * Query HPO term by name. + * + * @param query The query string. + * @returns Promise with the response from the server. + * @throws StatusCodeNotOk if the request fails. + * @throws InvalidResponseContent if the response is not valid JSON. + */ async queryHpoTermsByName(query: string): Promise { const url = `${this.apiBaseUrl}/hpo/terms?name=${query}` const response = await fetch(url, { @@ -75,18 +123,24 @@ export class VigunoClient { if (!response.ok) { const errorBody = await response.json() - throw new Error(errorBody.msg || response.statusText) + throw new StatusCodeNotOk(errorBody.msg || response.statusText) } - const responseJson = await response.json() - return HpoTermResult.fromJson(responseJson) + try { + const responseJson = await response.json() + return HpoTermResult.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse HPO response: ${e}`) + } } /** * Retrieves HPO terms associated with a gene. * * @param hgncId HGNC ID of the gene to query for. - * @returns Response of the server + * @returns Promise with the response from the server. + * @throws StatusCodeNotOk if the request fails. + * @throws InvalidResponseContent if the response is not valid JSON. */ async fetchHpoTermsForHgncId(hgncId: string): Promise { const url = `${this.apiBaseUrl}/hpo/genes?gene_id=${hgncId}&hpo_terms=true` @@ -96,10 +150,14 @@ export class VigunoClient { if (!response.ok) { const errorBody = await response.json() - throw new Error(errorBody.msg || response.statusText) + throw new StatusCodeNotOk(errorBody.msg || response.statusText) } - const responseJson = await response.json() - return HpoGenesResult.fromJson(responseJson) + try { + const responseJson = await response.json() + return HpoGenesResult.fromJson(responseJson) + } catch (e) { + throw new InvalidResponseContent(`failed to parse HPO response: ${e}`) + } } } diff --git a/src/components/GeneLiteratureCard/GeneLiteratureCard.vue b/src/components/GeneLiteratureCard/GeneLiteratureCard.vue index b840550..3f447ae 100644 --- a/src/components/GeneLiteratureCard/GeneLiteratureCard.vue +++ b/src/components/GeneLiteratureCard/GeneLiteratureCard.vue @@ -30,6 +30,12 @@ const props = withDefaults( } ) +/** This component's emits. */ +const emit = defineEmits<{ + /** An error occured, e.g., communicating with server. */ + error: [msg: string] +}>() + /** Vuetify theme. */ const theme = useTheme() @@ -146,6 +152,7 @@ const loadPubTator = async () => { try { await pubtatorStore.initialize(props.geneInfo?.hgnc?.symbol) } catch (e) { + emit('error', `Error loading PubTator 3 data: ${e}`) errorMessage.value = `Error loading PubTator 3 data: ${e}` } } diff --git a/src/components/SeqvarToolsCard/SeqvarToolsCard.vue b/src/components/SeqvarToolsCard/SeqvarToolsCard.vue index b7060f0..762af42 100644 --- a/src/components/SeqvarToolsCard/SeqvarToolsCard.vue +++ b/src/components/SeqvarToolsCard/SeqvarToolsCard.vue @@ -13,6 +13,12 @@ const props = defineProps<{ varAnnos?: SeqvarInfoResult }>() +/** This component's emits. */ +const emit = defineEmits<{ + /** An error occured, e.g., communicating with server. */ + error: [msg: string] +}>() + const ucscLinkout = computed(() => { if (!props.seqvar) { return '#' @@ -127,7 +133,7 @@ const jumpToLocus = async () => { }` ).catch((e) => { const msg = "Couldn't connect to IGV. Please make sure IGV is running and try again." - alert(msg) + emit('error', msg) console.error(msg, e) }) } diff --git a/src/components/SeqvarVariantValidatorCard/SeqvarVariantValidatorCard.vue b/src/components/SeqvarVariantValidatorCard/SeqvarVariantValidatorCard.vue index 823ca96..73d93be 100644 --- a/src/components/SeqvarVariantValidatorCard/SeqvarVariantValidatorCard.vue +++ b/src/components/SeqvarVariantValidatorCard/SeqvarVariantValidatorCard.vue @@ -23,6 +23,12 @@ interface Props { const props = defineProps() +/** This component's emits. */ +const emit = defineEmits<{ + /** An error occured, e.g., communicating with server. */ + error: [msg: string] +}>() + const variantValidatorState = ref(VariantValidatorStates.Initial) interface KeyValue { @@ -70,6 +76,7 @@ const queryVariantValidatorApi = async () => { variantValidatorState.value = VariantValidatorStates.Done } catch (err) { variantValidatorState.value = VariantValidatorStates.Error + emit('error', `There was an error when communicating with VariantValidator API: ${err}`) return } } diff --git a/src/components/StrucvarToolsCard/StrucvarToolsCard.vue b/src/components/StrucvarToolsCard/StrucvarToolsCard.vue index 47449f3..64d271a 100644 --- a/src/components/StrucvarToolsCard/StrucvarToolsCard.vue +++ b/src/components/StrucvarToolsCard/StrucvarToolsCard.vue @@ -9,6 +9,12 @@ const props = defineProps<{ strucvar?: Strucvar }>() +/** This component's emits. */ +const emit = defineEmits<{ + /** An error occured, e.g., communicating with server. */ + error: [msg: string] +}>() + const svStop = (strucvar: Strucvar): number => { return strucvar.svType === 'INS' || strucvar.svType === 'BND' ? strucvar.start + 1 : strucvar.stop } @@ -96,7 +102,7 @@ const jumpToLocus = async () => { `http://127.0.0.1:60151/goto?locus=${chrPrefixed}:${props.strucvar?.start}-${svStop(props.strucvar!)}` ).catch((e) => { const msg = "Couldn't connect to IGV. Please make sure IGV is running and try again." - alert(msg) + emit('error', msg) console.error(msg, e) }) } diff --git a/src/lib/urlConfig.ts b/src/lib/urlConfig.ts index c1bedea..6ea1410 100644 --- a/src/lib/urlConfig.ts +++ b/src/lib/urlConfig.ts @@ -32,3 +32,31 @@ export interface UrlConfig { * Global URL configuration instance. */ export const urlConfig: UrlConfig = {} + +/** + * Helper that performs setup of the global URL configuration for testing. + */ +export const setupUrlConfigForTesting = () => { + urlConfig.baseUrlAnnonars = '/internal/proxy/annonars' + urlConfig.baseUrlMehari = '/internal/proxy/mehari' + urlConfig.baseUrlViguno = '/internal/proxy/viguno' + urlConfig.baseUrlCadaPrio = '/internal/proxy/cada-prio' + urlConfig.baseUrlDotty = '/internal/proxy/dotty' + urlConfig.baseUrlNginx = '/remote/variantvalidator' + urlConfig.baseUrlVariantValidator = '/remote/variantvalidator' + urlConfig.baseUrlPubtator = '/remote/pubtator3-api' +} + +/** + * Helper that resets the global URL configuration for testing. + */ +export const resetUrlConfig = () => { + urlConfig.baseUrlAnnonars = undefined + urlConfig.baseUrlMehari = undefined + urlConfig.baseUrlViguno = undefined + urlConfig.baseUrlCadaPrio = undefined + urlConfig.baseUrlDotty = undefined + urlConfig.baseUrlNginx = undefined + urlConfig.baseUrlVariantValidator = undefined + urlConfig.baseUrlPubtator = undefined +} diff --git a/src/stores/geneInfo/__snapshots__/store.spec.ts.snap b/src/stores/geneInfo/__snapshots__/store.spec.ts.snap index 792665c..c5a4c36 100644 --- a/src/stores/geneInfo/__snapshots__/store.spec.ts.snap +++ b/src/stores/geneInfo/__snapshots__/store.spec.ts.snap @@ -24226,7 +24226,7 @@ exports[`geneInfo Store > should load data 2`] = ` exports[`geneInfo Store > should load data 3`] = ` [ { - "biotype": "TRANSCRIPT_BIOTYPE_CODING", + "biotype": 1, "geneId": "HGNC:1100", "geneSymbol": "BRCA1", "genomeAlignments": [ @@ -24417,10 +24417,11 @@ exports[`geneInfo Store > should load data 3`] = ` "altEndI": 41277381, "altStartI": 41277287, "cigar": "94M", + "ord": 0, }, ], - "genomeBuild": "GENOME_BUILD_GRCH37", - "strand": "STRAND_MINUS", + "genomeBuild": 1, + "strand": 2, }, ], "id": "NM_007294.4", @@ -24428,12 +24429,12 @@ exports[`geneInfo Store > should load data 3`] = ` "startCodon": 113, "stopCodon": 5705, "tags": [ - "TRANSCRIPT_TAG_MANE_SELECT", - "TRANSCRIPT_TAG_REF_SEQ_SELECT", + 3, + 5, ], }, { - "biotype": "TRANSCRIPT_BIOTYPE_CODING", + "biotype": 1, "geneId": "HGNC:1100", "geneSymbol": "BRCA1", "genomeAlignments": [ @@ -24616,19 +24617,21 @@ exports[`geneInfo Store > should load data 3`] = ` "altEndI": 41277381, "altStartI": 41277293, "cigar": "88M", + "ord": 0, }, ], - "genomeBuild": "GENOME_BUILD_GRCH37", - "strand": "STRAND_MINUS", + "genomeBuild": 1, + "strand": 2, }, ], "id": "NM_007297.4", "protein": "NP_009228.2", "startCodon": 194, "stopCodon": 5645, + "tags": [], }, { - "biotype": "TRANSCRIPT_BIOTYPE_CODING", + "biotype": 1, "geneId": "HGNC:1100", "geneSymbol": "BRCA1", "genomeAlignments": [ @@ -24811,19 +24814,21 @@ exports[`geneInfo Store > should load data 3`] = ` "altEndI": 41276132, "altStartI": 41276033, "cigar": "99M", + "ord": 0, }, ], - "genomeBuild": "GENOME_BUILD_GRCH37", - "strand": "STRAND_MINUS", + "genomeBuild": 1, + "strand": 2, }, ], "id": "NM_007298.3", "protein": "NP_009229.2", "startCodon": 19, "stopCodon": 2299, + "tags": [], }, { - "biotype": "TRANSCRIPT_BIOTYPE_CODING", + "biotype": 1, "geneId": "HGNC:1100", "geneSymbol": "BRCA1", "genomeAlignments": [ @@ -25006,19 +25011,21 @@ exports[`geneInfo Store > should load data 3`] = ` "altEndI": 41277381, "altStartI": 41277293, "cigar": "88M", + "ord": 0, }, ], - "genomeBuild": "GENOME_BUILD_GRCH37", - "strand": "STRAND_MINUS", + "genomeBuild": 1, + "strand": 2, }, ], "id": "NM_007299.4", "protein": "NP_009230.2", "startCodon": 107, "stopCodon": 2207, + "tags": [], }, { - "biotype": "TRANSCRIPT_BIOTYPE_CODING", + "biotype": 1, "geneId": "HGNC:1100", "geneSymbol": "BRCA1", "genomeAlignments": [ @@ -25217,16 +25224,18 @@ exports[`geneInfo Store > should load data 3`] = ` "altEndI": 41277381, "altStartI": 41277287, "cigar": "94M", + "ord": 0, }, ], - "genomeBuild": "GENOME_BUILD_GRCH37", - "strand": "STRAND_MINUS", + "genomeBuild": 1, + "strand": 2, }, ], "id": "NM_007300.4", "protein": "NP_009231.2", "startCodon": 113, "stopCodon": 5768, + "tags": [], }, ] `; diff --git a/src/stores/pubtator/store.ts b/src/stores/pubtator/store.ts index 7cd3f97..be72768 100644 --- a/src/stores/pubtator/store.ts +++ b/src/stores/pubtator/store.ts @@ -18,7 +18,13 @@ export const usePubtatorStore = defineStore('pubtator', () => { /** Detailed result information. */ const searchResults = ref({}) - /** Initialize the store for the given HGNC symbol. */ + /** + * Initialize the store for the given HGNC symbol. + * + * @param hgncSymbol$ HGNC symbol to initialize the store for. + * @param force Whether to force re-initialization. + * @throws Error if the search fails. + */ const initialize = async (hgncSymbol$?: string, force: boolean = false) => { // Skip if already loaded if (!force && hgncSymbol$ === hgncSymbol.value) { diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index ce9c2fe..1b2fbfb 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -1,6 +1,6 @@ import 'vitest-canvas-mock' -import { urlConfig } from './lib/urlConfig' +import { setupUrlConfigForTesting } from './lib/urlConfig' // Fix undefined ResizeObserver error class ResizeObserverStub { @@ -12,11 +12,4 @@ class ResizeObserverStub { window.ResizeObserver = window.ResizeObserver || ResizeObserverStub // Define base URLs for API calls in tests. -urlConfig.baseUrlAnnonars = '/internal/proxy/annonars' -urlConfig.baseUrlMehari = '/internal/proxy/mehari' -urlConfig.baseUrlViguno = '/internal/proxy/viguno' -urlConfig.baseUrlCadaPrio = '/internal/proxy/cada-prio' -urlConfig.baseUrlDotty = '/internal/proxy/dotty' -urlConfig.baseUrlNginx = '/remote/variantvalidator' -urlConfig.baseUrlVariantValidator = '/remote/variantvalidator' -urlConfig.baseUrlPubtator = '/remote/pubtator3-api' +setupUrlConfigForTesting()