From 99ceaee8bef4b7010f33050ca4b420661c7f6935 Mon Sep 17 00:00:00 2001 From: Manuel Holtgrewe Date: Tue, 30 Jan 2024 16:25:06 +0100 Subject: [PATCH] feat: adding store/seqvarInfo, api/viguno, more api/annonars (#50) --- src/api/annonars/client.ts | 3 +- src/api/annonars/types.ts | 169 +++++++++++++- src/api/viguno/client.spec.ts | 211 ++++++++++++++++++ src/api/viguno/client.ts | 95 ++++++++ src/api/viguno/index.ts | 2 + .../viguno}/types.ts | 8 + .../GeneConditionsCard/GeneConditionsCard.vue | 2 +- src/store/seqvarInfo/index.ts | 1 + src/store/seqvarInfo/store.ts | 133 +++++++++++ 9 files changed, 621 insertions(+), 3 deletions(-) create mode 100644 src/api/viguno/client.spec.ts create mode 100644 src/api/viguno/client.ts create mode 100644 src/api/viguno/index.ts rename src/{components/GeneConditionsCard => api/viguno}/types.ts (57%) create mode 100644 src/store/seqvarInfo/index.ts create mode 100644 src/store/seqvarInfo/store.ts diff --git a/src/api/annonars/client.ts b/src/api/annonars/client.ts index f751590..1f9ba6a 100644 --- a/src/api/annonars/client.ts +++ b/src/api/annonars/client.ts @@ -1,6 +1,7 @@ import { chunks } from '@reactgular/chunks' import type { LinearStrucvar, Seqvar } from '../../lib/genomicVars' +import { ClinvarPerGeneRecord } from '../../pbs/annonars/clinvar/per_gene' import { Record as GeneInfoRecord } from '../../pbs/annonars/genes/base' import { GeneInfoResult } from './types' @@ -61,7 +62,7 @@ export class AnnonarsClient { * @param hgncId * @returns */ - async fetchGeneClinvarInfo(hgncId: string): Promise { + async fetchGeneClinvarInfo(hgncId: string): Promise { const response = await fetch(`${this.apiBaseUrl}genes/clinvar?hgnc_id=${hgncId}`, { method: 'GET' }) diff --git a/src/api/annonars/types.ts b/src/api/annonars/types.ts index 6916a8e..b4517e6 100644 --- a/src/api/annonars/types.ts +++ b/src/api/annonars/types.ts @@ -1,7 +1,174 @@ +import { JsonValue } from '@protobuf-ts/runtime' + +import { Record as ClinvarRecord } from '../../pbs/annonars/clinvar/minimal' +import { Record as UcscConservationRecord } from '../../pbs/annonars/cons/base' +import { Record as DbsnpRecord } from '../../pbs/annonars/dbsnp/base' import { Record as GeneInfoRecord } from '../../pbs/annonars/genes/base' +import { Record as Gnomad2Record } from '../../pbs/annonars/gnomad/gnomad2' +import { Record as Gnomad3Record } from '../../pbs/annonars/gnomad/gnomad3' +import { Record as Gnomad4Record } from '../../pbs/annonars/gnomad/gnomad4' +import { Record as GnomadMtdnaRecord } from '../../pbs/annonars/gnomad/mtdna' +import { Record as HelixmtdbRecord } from '../../pbs/annonars/helixmtdb/base' -/** Interface for gene info result. */ +/** + * Interface for gene info result. + */ export interface GeneInfoResult { /** Gehe information per HGNC ID. */ genes: GeneInfoRecord[] } + +/** + * Interface for seqvar info query as returned by API. + */ +export interface SeqvarInfoQuery$Api { + genome_release: string + chromosome: string + pos: number + reference: string + alternative: string +} + +/** + * Interface for seqvar info query. + */ +export interface SeqvarInfoQuery { + genomeRelease: string + chromosome: string + pos: number + reference: string + alternative: string +} + +/** + * Helper class to convert `SeqvarInfoQuery$Api` to `SeqvarInfoQuery`. + */ +class SeqvarInfoQuery$Type { + fromJson(apiQuery: SeqvarInfoQuery$Api): SeqvarInfoQuery { + return { + genomeRelease: apiQuery.genome_release, + chromosome: apiQuery.chromosome, + pos: apiQuery.pos, + reference: apiQuery.reference, + alternative: apiQuery.alternative + } + } +} + +/** + * Helper instance to convert `SeqvarInfoQuery$Api` to `SeqvarInfoQuery`. + */ +export const SeqvarInfoQuery = new SeqvarInfoQuery$Type() + +/** + * Interface for seqvar info query result as returned by API. + */ +export interface SeqvarInfoResult$Api { + cadd: { [key: string]: number | string | boolean | null } | null + dbsnp: DbsnpRecord | null + dbnsfp: { [key: string]: number | string | boolean | null } | null + dbscsnv: { [key: string]: number | string | boolean | null } | null + gnomad_mtdna: GnomadMtdnaRecord | null + gnomad_exomes: Gnomad2Record | Gnomad3Record | Gnomad4Record | null + gnomad_genomes: Gnomad2Record | Gnomad3Record | Gnomad4Record | null + helixmtdb: HelixmtdbRecord | null + ucsc_conservation: UcscConservationRecord[] + clinvar: null +} + +/** + * Interface for seqvar info result. + */ +export interface SeqvarInfoResult { + cadd?: { [key: string]: number | string | boolean | null } + dbsnp?: DbsnpRecord + dbnsfp?: { [key: string]: number | string | boolean | null } + dbscsnv?: { [key: string]: number | string | boolean | null } + gnomadMtdna?: GnomadMtdnaRecord + gnomadExomes?: Gnomad2Record | Gnomad3Record | Gnomad4Record + gnomadGenomes?: Gnomad2Record | Gnomad3Record | Gnomad4Record + helixmtdb?: HelixmtdbRecord + ucscConservation: UcscConservationRecord[] + clinvar?: ClinvarRecord +} + +/** + * Helper to convert + */ + +/** + * Helper class to convert `SeqvarInfoResult$Api` to `SeqvarInfoResult`. + */ +class SeqvarInfoResult$Type { + fromJson(apiResult: SeqvarInfoResult$Api): SeqvarInfoResult { + return { + cadd: apiResult.cadd === null ? undefined : apiResult.cadd, + dbsnp: + // @ts-ignore + apiResult.dbsnp === null + ? undefined // @ts-ignore + : DbsnpRecord.fromJson(apiResult.dbsnp as JsonValue), + dbnsfp: apiResult.dbnsfp === null ? undefined : apiResult.dbnsfp, + dbscsnv: apiResult.dbscsnv === null ? undefined : apiResult.dbscsnv, + gnomadMtdna: + apiResult.gnomad_mtdna === null + ? undefined + : // @ts-ignore + GnomadMtdnaRecord.fromJson(apiResult.gnomad_mtdna as JsonValue), + gnomadExomes: apiResult.gnomad_exomes === null ? undefined : apiResult.gnomad_exomes, + gnomadGenomes: apiResult.gnomad_genomes === null ? undefined : apiResult.gnomad_genomes, + helixmtdb: + apiResult.helixmtdb === null + ? undefined + : // @ts-ignore + HelixmtdbRecord.fromJson(apiResult.helixmtdb as JsonValue), + ucscConservation: apiResult.ucsc_conservation, + clinvar: + apiResult.clinvar === null + ? undefined + : // @ts-ignore + ClinvarRecord.fromJson(apiResult.clinvar as JsonValue) + } + } +} + +/** + * Helper instance to convert `SeqvarInfoResult$Api` to `SeqvarInfoResult`. + */ +export const SeqvarInfoResult = new SeqvarInfoResult$Type() + +/** + * Interface for seqvar info query response as returned by API. + */ +export interface SeqvarInfoResponse$Api { + server_version: string + query: SeqvarInfoQuery$Api + result: SeqvarInfoResult$Api +} + +/** + * Interface for seqvar info query result. + */ +export interface SeqvarInfoResponse { + serverVersion: string + query: SeqvarInfoQuery + result: SeqvarInfoResult +} + +/** + * Helper class to convert `SeqvarInfoResponse$Api` to `SeqvarInfoResponse`. + */ +class SeqvarInfoResponse$Type { + static fromJson(apiResponse: SeqvarInfoResponse$Api): SeqvarInfoResponse { + return { + serverVersion: apiResponse.server_version, + query: SeqvarInfoQuery.fromJson(apiResponse.query), + result: SeqvarInfoResult.fromJson(apiResponse.result) + } + } +} + +/** + * Helper instance to convert `SeqvarInfoResponse$Api` to `SeqvarInfoResponse`. + */ +export const SeqvarInfoResponse = new SeqvarInfoResponse$Type() diff --git a/src/api/viguno/client.spec.ts b/src/api/viguno/client.spec.ts new file mode 100644 index 0000000..ba3a79c --- /dev/null +++ b/src/api/viguno/client.spec.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import createFetchMock from 'vitest-fetch-mock' + +import { VigunoClient } from './client' + +const fetchMocker = createFetchMock(vi) + +describe.concurrent('Viguno Client', () => { + beforeEach(() => { + fetchMocker.enableMocks() + fetchMocker.resetMocks() + }) + + it('resolves OMIM term by ID correctly', async () => { + // arrange: + const mockOmimTerm = { id: 'OMIM:123456', name: 'Example Disease' } + fetchMocker.mockResponseOnce(JSON.stringify(mockOmimTerm)) + + // act: + const client = new VigunoClient() + const result = await client.resolveOmimTermById('123456') + + // assert: + expect(result).toEqual(mockOmimTerm) + }) + + it('handles non-existent OMIM term ID', async () => { + // arrange: + const errorMessage = 'OMIM term not found' + fetchMocker.mockResponseOnce(JSON.stringify({ msg: errorMessage }), { status: 404 }) + + // act: + const client = new VigunoClient() + + // assert: + await expect(client.resolveOmimTermById('999999')).rejects.toThrow(errorMessage) + }) + + it('handles server error when resolving OMIM term by ID', async () => { + // arrange: + const errorMessage = 'Internal Server Error' + fetchMocker.mockResponseOnce(JSON.stringify({ msg: errorMessage }), { status: 500 }) + + // act: + const client = new VigunoClient() + + // assert: + await expect(client.resolveOmimTermById('123456')).rejects.toThrow(errorMessage) + }) + + it('handles network error when resolving OMIM term by ID', async () => { + // arrange: + fetchMocker.mockReject(new Error('Network Error')) + + // act: + const client = new VigunoClient() + + // assert: + await expect(client.resolveOmimTermById('123456')).rejects.toThrow('Network Error') + }) + + it('returns a list of OMIM terms for a valid query', async () => { + // arrange: + const mockResponse = [ + { id: 'OMIM:123456', name: 'Example Disease 1' }, + { id: 'OMIM:234567', name: 'Example Disease 2' } + ] + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)) + + // act: + const client = new VigunoClient() + const result = await client.queryOmimTermsByName('Example') + + // assert: + expect(result).toEqual(mockResponse) + }) + + it('returns an empty list for a query with no results', async () => { + // arrange: + fetchMocker.mockResponseOnce(JSON.stringify([])) + + // act: + const client = new VigunoClient() + const result = await client.queryOmimTermsByName('NonExistentDisease') + + // assert: + expect(result).toEqual([]) + }) + + it('handles server error for a query', async () => { + // arrange: + const errorMessage = 'Internal Server Error' + fetchMocker.mockResponseOnce(JSON.stringify({ msg: errorMessage }), { status: 500 }) + + // act: + const client = new VigunoClient() + + // assert: + await expect(client.queryOmimTermsByName('Example')).rejects.toThrow(errorMessage) + }) + + it('handles network error during a query', async () => { + // arrange: + fetchMocker.mockReject(new Error('Network Error')) + + // act: + const client = new VigunoClient() + + // assert: + await expect(client.queryOmimTermsByName('Example')).rejects.toThrow('Network Error') + }) + + it('resolves HPO term by ID correctly', async () => { + // arrange: + const mockHpoTerm = { id: 'HP:0000118', name: 'Phenotypic abnormality' } + fetchMocker.mockResponseOnce(JSON.stringify(mockHpoTerm)) + + // act: + const client = new VigunoClient() + const result = await client.resolveHpoTermById('0000118') + + // assert: + expect(result).toEqual(mockHpoTerm) + }) + + it('handles non-existent HPO term ID', async () => { + // arrange: + const errorMessage = 'HPO term not found' + fetchMocker.mockResponseOnce(JSON.stringify({ msg: errorMessage }), { status: 404 }) + + // act: + const client = new VigunoClient() + + // assert: + await expect(client.resolveHpoTermById('9999999')).rejects.toThrow(errorMessage) + }) + + it('handles server error when resolving HPO term by ID', async () => { + // arrange: + const errorMessage = 'Internal Server Error' + fetchMocker.mockResponseOnce(JSON.stringify({ msg: errorMessage }), { status: 500 }) + + // act: + const client = new VigunoClient() + + // assert: + await expect(client.resolveHpoTermById('0000118')).rejects.toThrow(errorMessage) + }) + + it('handles network error when resolving HPO term by ID', async () => { + // arrange: + fetchMocker.mockReject(new Error('Network Error')) + + // act: + const client = new VigunoClient() + + // assert: + await expect(client.resolveHpoTermById('0000118')).rejects.toThrow('Network Error') + }) + + it('queries HPO terms by name correctly', async () => { + // arrange: + const mockHpoTerms = [ + { id: 'HP:0000118', name: 'Phenotypic abnormality' }, + { id: 'HP:0000152', name: 'Abnormality of head or neck' } + ] + fetchMocker.mockResponseOnce(JSON.stringify(mockHpoTerms)) + + // act: + const client = new VigunoClient() + const result = await client.queryHpoTermsByName('Phenotypic') + + // assert: + expect(result).toEqual(mockHpoTerms) + }) + + it('returns an empty list for a name query with no results', async () => { + // arrange: + fetchMocker.mockResponseOnce(JSON.stringify([])) + + // act: + const client = new VigunoClient() + const result = await client.queryHpoTermsByName('NonExistentTerm') + + // assert: + expect(result).toEqual([]) + }) + + it('handles server error during name query', async () => { + // arrange: + const errorMessage = 'Internal Server Error' + fetchMocker.mockResponseOnce(JSON.stringify({ msg: errorMessage }), { status: 500 }) + + // act: + const client = new VigunoClient() + + // assert: + await expect(client.queryHpoTermsByName('Phenotypic')).rejects.toThrow(errorMessage) + }) + + it('handles network error during name query', async () => { + // arrange: + fetchMocker.mockReject(new Error('Network Error')) + + // act: + const client = new VigunoClient() + + // assert: + await expect(client.queryHpoTermsByName('Phenotypic')).rejects.toThrow('Network Error') + }) +}) diff --git a/src/api/viguno/client.ts b/src/api/viguno/client.ts new file mode 100644 index 0000000..923f0bb --- /dev/null +++ b/src/api/viguno/client.ts @@ -0,0 +1,95 @@ +import { HpoTerm } from './types' + +/** Base URL for viguno API access */ +const API_BASE_URL = '/internal/proxy/viguno/' + +export class VigunoClient { + private apiBaseUrl: string + + constructor(apiBaseUrl?: string) { + this.apiBaseUrl = apiBaseUrl ?? API_BASE_URL + } + + async resolveOmimTermById(id: string): Promise { + const url = `${this.apiBaseUrl}hpo/omims?omim_id=${id}` + const response = await fetch(url, { + method: 'GET' + }) + + if (!response.ok) { + const errorBody = await response.json() + throw new Error(errorBody.msg || response.statusText) + } + + return await response.json() + } + + async queryOmimTermsByName(query: string, matchType: string = 'contains'): Promise { + const url = `${this.apiBaseUrl}hpo/omims?name=${query}&match=${matchType}` + const response = await fetch(url, { + method: 'GET' + }) + + if (!response.ok) { + const errorBody = await response.json() + throw new Error(errorBody.msg || response.statusText) + } + + return await response.json() + } + + async resolveHpoTermById(id: string): Promise { + const url = `${this.apiBaseUrl}hpo/terms?term_id=${id}` + const response = await fetch(url, { + method: 'GET' + }) + + if (!response.ok) { + const errorBody = await response.json() + throw new Error(errorBody.msg || response.statusText) + } + + return await response.json() + } + + async queryHpoTermsByName(query: string): Promise { + const url = `${this.apiBaseUrl}hpo/terms?name=${query}` + const response = await fetch(url, { + method: 'GET' + }) + + if (!response.ok) { + const errorBody = await response.json() + throw new Error(errorBody.msg || response.statusText) + } + + return await response.json() + } + + /** + * Retrieves HPO terms associated with a gene. + * + * @param hgncId + * @returns List of HPO terms associated with the gene. + */ + async fetchHpoTermsForHgncId(hgncId: string): Promise { + const url = `${this.apiBaseUrl}hpo/genes?gene_id=${hgncId}&hpo_terms=true` + const response = await fetch(url, { + method: 'GET' + }) + + if (!response.ok) { + const errorBody = await response.json() + throw new Error(errorBody.msg || response.statusText) + } + + const res = await response.json() + if (!res.result?.length || res.result.length === 0) { + return [] + } + return (res.result[0].hpo_terms ?? []).map((term: HpoTerm) => ({ + term_id: term.term_id, + name: term.name + })) + } +} diff --git a/src/api/viguno/index.ts b/src/api/viguno/index.ts new file mode 100644 index 0000000..886d07b --- /dev/null +++ b/src/api/viguno/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './client' diff --git a/src/components/GeneConditionsCard/types.ts b/src/api/viguno/types.ts similarity index 57% rename from src/components/GeneConditionsCard/types.ts rename to src/api/viguno/types.ts index 15f173f..210331b 100644 --- a/src/components/GeneConditionsCard/types.ts +++ b/src/api/viguno/types.ts @@ -7,3 +7,11 @@ export interface HpoTerm { /** Term definition, if any. */ definition?: string } + +/** Gene as returned by the API. */ +export interface Gene { + gene_ncbi_id: number + gene_symbol: string + hgnc_id: string + hpo_terms?: HpoTerm[] +} diff --git a/src/components/GeneConditionsCard/GeneConditionsCard.vue b/src/components/GeneConditionsCard/GeneConditionsCard.vue index 5cdccbf..89525b4 100644 --- a/src/components/GeneConditionsCard/GeneConditionsCard.vue +++ b/src/components/GeneConditionsCard/GeneConditionsCard.vue @@ -2,6 +2,7 @@ import { titleCase } from 'title-case' import { computed, onMounted, ref, useSlots, watch } from 'vue' +import type { HpoTerm } from '../../api/viguno/types' import { ConditionsRecord, ConditionsRecord_GeneDiseaseAssociation, @@ -13,7 +14,6 @@ import { PanelAppRecord_ConfidenceLevel } from '../../pbs/annonars/genes/base' import DocsLink from '../DocsLink/DocsLink.vue' -import type { HpoTerm } from './types' /** This component's props. */ const props = withDefaults( diff --git a/src/store/seqvarInfo/index.ts b/src/store/seqvarInfo/index.ts new file mode 100644 index 0000000..16c8633 --- /dev/null +++ b/src/store/seqvarInfo/index.ts @@ -0,0 +1 @@ +export * from './store' diff --git a/src/store/seqvarInfo/store.ts b/src/store/seqvarInfo/store.ts new file mode 100644 index 0000000..b836378 --- /dev/null +++ b/src/store/seqvarInfo/store.ts @@ -0,0 +1,133 @@ +/** + * Store for variant details. + * + * This includes the data retrieved from the APIs. + */ +import equal from 'fast-deep-equal' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import { AnnonarsClient } from '../../api/annonars' +import { MehariClient } from '../../api/mehari' +import { type HpoTerm, VigunoClient } from '../../api/viguno' +import { type Seqvar } from '../../lib/genomicVars' +import { StoreState } from '../../store' + +export const useSeqvarInfoStore = defineStore('seqvarInfo', () => { + /** The current store state. */ + const storeState = ref(StoreState.Initial) + + /** The Seqvar that the previous record has been retrieved for. */ + const seqvar = ref(undefined) + + /** Variant-related information from annonars. */ + const varAnnos = ref(null) + + /** ClinVar gene-related information from annoars. */ + const geneClinvar = ref(null) + + /** Information about related gene. */ + const geneInfo = ref(null) + + /** The HPO terms retrieved from viguno. */ + const hpoTerms = ref([]) + + /** Transcript consequence information from mehari. */ + const txCsq = ref(null) + + /** Promise for initialization of the store. */ + const loadDataRes = ref | null>(null) + + /** Clear all data in the store. */ + const clearData = () => { + storeState.value = StoreState.Initial + seqvar.value = undefined + varAnnos.value = null + txCsq.value = null + geneInfo.value = null + geneClinvar.value = null + } + + /** + * Load data from the server. + * + * @param seqvar$ The sequence variant to use for the query. + * @param forceReload Whether to force-reload in case the variant is the same. + * @returns + */ + const loadData = async (seqvar$: Seqvar, forceReload: boolean = false) => { + // Protect against loading multiple times. + if (!forceReload && storeState.value !== StoreState.Initial && equal(seqvar$, seqvar.value)) { + return loadDataRes.value + } + + // Clear against artifact + clearData() + + // Load data via API + storeState.value = StoreState.Loading + const annonarsClient = new AnnonarsClient() + const mehariClient = new MehariClient() + const vigunoClient = new VigunoClient() + let hgncId = '' + + // Retrieve variant information from annonars and mehari. + loadDataRes.value = Promise.all([ + annonarsClient.fetchVariantInfo(seqvar$).then((data) => { + varAnnos.value = data.result + }), + mehariClient.retrieveSeqvarsCsq(seqvar$).then((data) => { + txCsq.value = data.result ?? [] + }) + ]) + .then((): Promise => { + if (txCsq.value.length !== 0) { + hgncId = txCsq.value[0].gene_id + return Promise.all([ + annonarsClient.fetchGeneInfo(hgncId).then((data) => { + for (const gene of data.genes) { + if (gene.hgnc!.hgncId === hgncId) { + geneInfo.value = gene + } + } + }), + annonarsClient.fetchGeneClinvarInfo(hgncId).then((data) => { + geneClinvar.value = data + }), + vigunoClient.fetchHpoTermsForHgncId(hgncId).then((data) => { + if (data === null) { + throw new Error('No HPO terms found.') + } + hpoTerms.value = data + }) + ]) + } else { + return Promise.resolve() + } + }) + .then(() => { + seqvar.value = seqvar$ + storeState.value = StoreState.Active + }) + .catch((err) => { + console.error('There was an error loading the variant data.', err) + clearData() + storeState.value = StoreState.Error + }) + + return loadDataRes.value + } + + return { + loadDataRes, + storeState, + seqvar, + varAnnos, + geneClinvar, + geneInfo, + hpoTerms, + txCsq, + loadData, + clearData + } +})