Skip to content

Commit

Permalink
feat: integrate dotty to allow querying for HGVS variants (#101)
Browse files Browse the repository at this point in the history
  • Loading branch information
holtgrewe committed Oct 4, 2023
1 parent 2ce7de2 commit 851b5fd
Show file tree
Hide file tree
Showing 10 changed files with 111 additions and 44 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
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>
12 changes: 6 additions & 6 deletions frontend/src/lib/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ describe.concurrent('isVariantMtHomopolymer method', () => {
})

describe.concurrent('search method', () => {
it('should return "gene" route location for HGNC queries', () => {
const result = search('HGNC:1100', 'ghcr37')
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 +121,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 +132,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 Down
30 changes: 23 additions & 7 deletions frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DottyClient } from '@/api/dotty'

/**
* Round `value` to `digits` and return an `<abbr>` tag that has the original value
* as the `@title` and the rounded value as the inner text. Optionally add a `label`
Expand Down Expand Up @@ -87,7 +89,26 @@ export const isVariantMtHomopolymer = (smallVar: any): boolean => {
* @param searchTerm The search term to use.
* @param genomeRelease The genome release to use.
*/
export const search = (searchTerm: string, genomeRelease: string) => {
export const search = async (searchTerm: string, genomeRelease: string) => {
// Remove leading/trailing whitespace.
searchTerm = searchTerm.trim()
if (!searchTerm) {
return // no query ;-)
}

// First, attempt to resolve using dotty.
const dottyClient = new DottyClient()
const result = await dottyClient.toSpdi(searchTerm)
if (result) {
const spdi = result.spdi
searchTerm = `${spdi.contig}:${spdi.pos}:${spdi.reference_deleted}:${spdi.alternate_inserted}`
if (!searchTerm.startsWith('chr')) {
searchTerm = `chr${searchTerm}`
}
console.log(searchTerm)
genomeRelease = spdi.assembly.toLowerCase()
}

interface RouteLocationFragment {
name: string
params?: any
Expand All @@ -110,7 +131,7 @@ export const search = (searchTerm: string, genomeRelease: string) => {
})
],
[
/^chr\d+:\d+:[A-Z]:[A-Z]$/,
/^chr\d+:\d+:[A-Z]+:[A-Z]+$/,
(): RouteLocationFragment => ({
name: 'variant',
params: {
Expand Down Expand Up @@ -142,11 +163,6 @@ export const search = (searchTerm: string, genomeRelease: string) => {
]

for (const [regexp, getRoute] of SEARCH_REGEXPS) {
// Remove trailing whitespace
searchTerm = searchTerm.trim()
if (!searchTerm) {
return null
}
if (regexp.test(searchTerm)) {
const routeLocation = getRoute()
// console.log(`term ${searchTerm} matched ${regexp}, route is`, routeLocation)
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/views/HomeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ const examples = [
'BRCA1',
'EMP',
'TP53',
'HGNC:1100',
'HGNC:777',
'NM_007294.4(BRCA1):c.5123C>A',
'chr17:41197708:T:G',
'chr17:41197751:G:T',
'DEL:chr17:41176312:41277500',
Expand All @@ -40,7 +39,7 @@ const useExample = (example: string) => {
* Otherwise log an error.
*/
const performSearch = async () => {
const routeLocation: any = search(searchTerm.value, genomeRelease.value)
const routeLocation: any = await search(searchTerm.value, genomeRelease.value)
if (routeLocation) {
router.push(routeLocation)
} else {
Expand Down

0 comments on commit 851b5fd

Please sign in to comment.