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 committed Mar 4, 2024
1 parent b9445ba commit d202bcb
Show file tree
Hide file tree
Showing 11 changed files with 325 additions and 107 deletions.
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)
}
}
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}`)
}
}
}
47 changes: 47 additions & 0 deletions src/api/common.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
27 changes: 19 additions & 8 deletions src/api/dotty/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { urlConfig } from '../../lib/urlConfig'
import { ConfigError, InvalidResponseContent } from '../common'
import { SpdiResult, TranscriptResult } from './types'

export class DottyClient {
Expand All @@ -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')
}
}

Expand All @@ -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<SpdiResult | null> {
const escapedQ = encodeURIComponent(q)
Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
@@ -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 }
Loading

0 comments on commit d202bcb

Please sign in to comment.