Skip to content

Commit

Permalink
feat: make components more robust in case of store/api errors (#160) (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
holtgrewe authored Mar 5, 2024
1 parent b9445ba commit 002a362
Show file tree
Hide file tree
Showing 25 changed files with 596 additions and 127 deletions.
35 changes: 34 additions & 1 deletion src/api/annonars/client.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
Expand Down
104 changes: 74 additions & 30 deletions src/api/annonars/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,34 +22,47 @@ 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')
}
}

/**
* 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<GeneInfoResult> {
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<SeqvarInfoResponse> {
const { genomeBuild, chrom, pos, del, ins } = seqvar
Expand All @@ -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<ClinvarPerGeneRecord> {
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}`)
}
}

Expand All @@ -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<GeneSearchResponse> {
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)
}

/**
Expand All @@ -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<GeneInfoRecord[]> {
const hgncIdChunks = chunks(hgncIds, chunkSize ?? 10)
Expand All @@ -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,
Expand All @@ -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)
}
}
2 changes: 1 addition & 1 deletion src/api/cadaPrio/__snapshots__/client.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -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}]}"`;
37 changes: 35 additions & 2 deletions src/api/cadaPrio/client.spec.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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()
Expand Down
19 changes: 14 additions & 5 deletions src/api/cadaPrio/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { urlConfig } from '../../lib/urlConfig'
import { ConfigError, InvalidResponseContent, StatusCodeNotOk } from '../common'
import { Response } from './types'

/**
Expand All @@ -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')
}
}

Expand All @@ -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<Response> {
const geneSuffix = geneSymbols ? `&gene_symbols=${geneSymbols.join(',')}` : ''
Expand All @@ -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}`)
}
}
}
Loading

0 comments on commit 002a362

Please sign in to comment.