Skip to content

Commit

Permalink
feat: integrate dotty to allow querying for HGVS variants (#101) (#110)
Browse files Browse the repository at this point in the history
  • Loading branch information
holtgrewe authored Oct 4, 2023
1 parent d16af74 commit 781f383
Show file tree
Hide file tree
Showing 15 changed files with 197 additions and 67 deletions.
2 changes: 2 additions & 0 deletions backend/app/api/internal/endpoints/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ async def reverse_proxy(request: Request) -> Response:
)
elif url.path.startswith(f"{settings.INTERNAL_STR}/proxy/nginx"):
backend_url = settings.BACKEND_PREFIX_NGINX + url.path.replace("/internal/proxy/nginx", "")
elif url.path.startswith(f"{settings.INTERNAL_STR}/proxy/dotty"):
backend_url = settings.BACKEND_PREFIX_DOTTY + url.path.replace("/internal/proxy/dotty", "")

if backend_url:
client = httpx.AsyncClient()
Expand Down
2 changes: 2 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ def assemble_cors_origins(cls, v: str | list[str]) -> list[str] | str: # pragma
BACKEND_PREFIX_VIGUNO: str = "http://viguno:8080"
#: Prefix for the backend of nginx service.
BACKEND_PREFIX_NGINX: str = "http://nginx:80"
#: Prefix for the backend of dotty service.
BACKEND_PREFIX_DOTTY: str = "http://dotty:8080"

#: URL to REDIS service.
REDIS_URL: str = "redis://redis:6379"
Expand Down
15 changes: 15 additions & 0 deletions backend/tests/api/internal/test_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ async def test_proxy_nginx(monkeypatch: MonkeyPatch, httpx_mock: HTTPXMock, clie
assert response.text == "Mocked response"


@pytest.mark.asyncio
async def test_proxy_dotty(monkeypatch: MonkeyPatch, httpx_mock: HTTPXMock, client: TestClient):
"""Test proxying to dotty backend."""
monkeypatch.setattr(settings, "BACKEND_PREFIX_DOTTY", f"http://{MOCKED_BACKEND_HOST}")
httpx_mock.add_response(
url=f"http://{MOCKED_BACKEND_HOST}/{MOCKED_URL_TOKEN}",
method="GET",
text="Mocked response",
)

response = client.get(f"/internal/proxy/dotty/{MOCKED_URL_TOKEN}")
assert response.status_code == 200
assert response.text == "Mocked response"


@pytest.mark.asyncio
async def test_invalid_proxy_route(client: TestClient):
"""Test invalid proxy route."""
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/__tests__/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AuthClient } from '@/api/auth'

const fetchMocker = createFetchMock(vi)

describe.concurrent('Auth Client', () => {
describe.concurrent('AuthClient', () => {
beforeEach(() => {
fetchMocker.enableMocks()
fetchMocker.resetMocks()
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/api/__tests__/dotty.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import createFetchMock from 'vitest-fetch-mock'

import { DottyClient, type DottyResponse } from '@/api/dotty'

const fetchMocker = createFetchMock(vi)

describe.concurrent('DottyClient', () => {
beforeEach(() => {
fetchMocker.enableMocks()
fetchMocker.resetMocks()
})

it('should resolve to SPDI successfully', async () => {
const mockData: DottyResponse = {
spdi: {
assembly: 'GRCh38',
contig: '13',
pos: 32319283,
reference_deleted: 'C',
alternate_inserted: 'A'
}
}
fetchMocker.mockResponseOnce(JSON.stringify(mockData), { status: 200 })

const client = new DottyClient()
const result = await client.toSpdi('NM_000059.3:c.274G>A')

expect(result).toEqual(mockData)
})
})
16 changes: 1 addition & 15 deletions frontend/src/api/common.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,6 @@
export const API_INTERNAL_BASE_PREFIX = '/internal/'
// import.meta.env.MODE == 'development' ? '//localhost:8080/internal/' : '/internal/'

export const API_INTERNAL_BASE_PREFIX_ANNONARS = '/internal/proxy/annonars'
// import.meta.env.MODE == 'development'
// ? `//localhost:8080/internal/proxy/annonars`
// : '/internal/proxy/annonars'

export const API_INTERNAL_BASE_PREFIX_MEHARI = '/internal/proxy/mehari'
// import.meta.env.MODE == 'development'
// ? '//localhost:8080/internal/proxy/mehari'
// : '/internal/proxy/mehari'

export const API_INTERNAL_BASE_PREFIX_NGINX = '/internal/proxy/nginx'
// import.meta.env.MODE == 'development'
// ? '//localhost:8080/internal/proxy/proxy/nginx'
// : '/internal/proxy/nginx'

export const API_INTERNAL_BASE_PREFIX_DOTTY = '/internal/proxy/dotty'
export const API_V1_BASE_PREFIX = '/api/v1/'
// import.meta.env.MODE == 'development' ? '//localhost:8080/api/v1/' : '/api/v1/'
45 changes: 45 additions & 0 deletions frontend/src/api/dotty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { API_INTERNAL_BASE_PREFIX_DOTTY } from '@/api/common'

const API_BASE_URL = `${API_INTERNAL_BASE_PREFIX_DOTTY}/`

/** SPDI representation as returned by dotty. */
export interface Spdi {
/** Assembly version. */
assembly: 'GRCh37' | 'GRCh38'
/** Contig. */
contig: string
/** Position. */
pos: number
/** Deleted sequence. */
reference_deleted: string
/** Inserted sequence. */
alternate_inserted: string
}

/** Response of a dotto query */
export interface DottyResponse {
/** SPDI returned by dotty. */
spdi: Spdi
}

export class DottyClient {
private apiBaseUrl: string
private csrfToken: string | null

constructor(apiBaseUrl?: string, csrfToken?: string) {
this.apiBaseUrl = apiBaseUrl ?? API_BASE_URL
this.csrfToken = csrfToken ?? null
}

async toSpdi(q: String, assembly: 'GRCh37' | 'GRCh38' = 'GRCh38'): Promise<DottyResponse | null> {
const url = `${API_INTERNAL_BASE_PREFIX_DOTTY}/api/v1/to-spdi?q=${q}&assembly=${assembly}`
const response = await fetch(url, {
method: 'GET'
})
if (response.status == 200) {
return await response.json()
} else {
return null
}
}
}
2 changes: 1 addition & 1 deletion frontend/src/components/HeaderDetailPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const genomeReleaseRef = ref(props.genomeRelease)
* Otherwise log an error.
*/
const performSearch = async () => {
const routeLocation: any = search(searchTermRef.value, genomeReleaseRef.value)
const routeLocation: any = await search(searchTermRef.value, genomeReleaseRef.value)
if (routeLocation) {
router.push(routeLocation)
} else {
Expand Down
26 changes: 14 additions & 12 deletions frontend/src/components/VariantDetails/VariationLandscape.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ const convertClinvarSignificance = (input: number): number => {
const vegaData = computed(() => {
let clinvarInfo = []
if (props.genomeRelease == 'grch37') {
clinvarInfo = props.clinvar.variants[0].variants
} else if (props.genomeRelease == 'grch38') {
clinvarInfo = props.clinvar.variants[1]
for (const item of props.clinvar.variants ?? []) {
if (item.genome_release.toLowerCase() == props.genomeRelease) {
clinvarInfo = item.variants
}
}
return clinvarInfo.map((variant: ClinvarVariant) => ({
Expand Down Expand Up @@ -291,13 +291,15 @@ const vegaLayer = [
<figcaption class="figure-caption text-center">
Variation Landscape of {{ props.geneSymbol }}
</figcaption>
<VegaPlot
:data-values="vegaData"
:encoding="vegaEncoding"
:layer="vegaLayer"
:width="1300"
:height="300"
renderer="svg"
/>
<div style="width: 1100px; height: 350px; overflow: none">
<VegaPlot
:data-values="vegaData"
:encoding="vegaEncoding"
:layer="vegaLayer"
:width="1000"
:height="300"
renderer="canvas"
/>
</div>
</figure>
</template>
10 changes: 9 additions & 1 deletion frontend/src/components/__tests__/HeaderDetailPage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'

import { DottyClient } from '@/api/dotty'
import SearchBar from '@/components/SearchBar.vue'
import { setupMountedComponents } from '@/lib/test-utils'
import { useGeneInfoStore } from '@/stores/geneInfo'
Expand All @@ -21,6 +22,10 @@ const geneData = {
}

describe.concurrent('HeaderDetailPage', async () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('renders the gene symbol and nav links', () => {
const { wrapper } = setupMountedComponents(
{ component: HeaderDetailPage, template: true },
Expand Down Expand Up @@ -60,6 +65,9 @@ describe.concurrent('HeaderDetailPage', async () => {
})

it('correctly emits search', async () => {
// we make `DottyClient.toSpdi` return null / fail
vi.spyOn(DottyClient.prototype, 'toSpdi').mockResolvedValue(null)

const { wrapper, router } = setupMountedComponents(
{ component: HeaderDetailPage, template: true },
{
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/components/__tests__/SearchBar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'

import { DottyClient } from '@/api/dotty'
import { setupMountedComponents } from '@/lib/test-utils'

import SearchBar from '../SearchBar.vue'

describe.concurrent('SearchBar.vue', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('renders the search bar with the correct default props', () => {
const { wrapper } = setupMountedComponents(
{ component: SearchBar, template: false },
Expand Down Expand Up @@ -61,6 +66,9 @@ describe.concurrent('SearchBar.vue', () => {
})

it('correctly emits search', async () => {
// we make `DottyClient.toSpdi` return null / fail
vi.spyOn(DottyClient.prototype, 'toSpdi').mockResolvedValue(null)

const { wrapper } = setupMountedComponents(
{ component: SearchBar, template: false },
{
Expand Down
58 changes: 34 additions & 24 deletions frontend/src/lib/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { DottyClient } from '@/api/dotty'

import {
copy,
Expand Down Expand Up @@ -109,9 +111,17 @@ describe.concurrent('isVariantMtHomopolymer method', () => {
})
})

describe.concurrent('search method', () => {
it('should return "gene" route location for HGNC queries', () => {
const result = search('HGNC:1100', 'ghcr37')
describe.concurrent('search method', async () => {
beforeEach(() => {
// we make `DottyClient.toSpdi` return null / fail every time
vi.spyOn(DottyClient.prototype, 'toSpdi').mockResolvedValue(null)
})
afterEach(() => {
vi.restoreAllMocks()
})

it('should return "gene" route location for HGNC queries', async () => {
const result = await search('HGNC:1100', 'ghcr37')
expect(result).toEqual({
name: 'gene',
params: {
Expand All @@ -121,8 +131,8 @@ describe.concurrent('search method', () => {
})
})

it('should return "variant" route location for Variant queries', () => {
const result = search('chr37:12345:A:G', 'ghcr37')
it('should return "variant" route location for Variant queries', async () => {
const result = await search('chr37:12345:A:G', 'ghcr37')
expect(result).toEqual({
name: 'variant',
params: {
Expand All @@ -132,8 +142,8 @@ describe.concurrent('search method', () => {
})
})

it('should return "genes" route location for general queries', () => {
const result = search('TP53', 'ghcr37')
it('should return "genes" route location for general queries', async () => {
const result = await search('TP53', 'ghcr37')
expect(result).toEqual({
name: 'genes',
query: {
Expand All @@ -142,6 +152,22 @@ describe.concurrent('search method', () => {
}
})
})

it('should return null if no entry', async () => {
const result = await search('', 'foo37')
expect(result).toBe(null)
})

it('should remove whitespace', async () => {
const result = await search(' HGNC:1100 ', 'ghcr37')
expect(result).toEqual({
name: 'gene',
params: {
searchTerm: 'HGNC:1100',
genomeRelease: 'ghcr37'
}
})
})
})

describe.concurrent('infoFromQuery method', () => {
Expand All @@ -166,22 +192,6 @@ describe.concurrent('infoFromQuery method', () => {
hgnc_id: undefined
})
})

it('should return null if no entry', () => {
const result = search('', 'foo37')
expect(result).toBe(null)
})

it('should remove whitespace', () => {
const result = search(' HGNC:1100 ', 'ghcr37')
expect(result).toEqual({
name: 'gene',
params: {
searchTerm: 'HGNC:1100',
genomeRelease: 'ghcr37'
}
})
})
})

describe.concurrent('copy method', () => {
Expand Down
Loading

0 comments on commit 781f383

Please sign in to comment.