Skip to content

Commit dcfb3b4

Browse files
committed
Adding HGNC API call
1 parent a946dc5 commit dcfb3b4

File tree

7 files changed

+181
-16
lines changed

7 files changed

+181
-16
lines changed

src-tauri/src/lib.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use std::{collections::HashMap, fs, sync::{Arc, Mutex}};
1515
use tauri_plugin_fs::{init};
1616

1717

18-
use crate::{dto::{pmid_dto::PmidDto, status_dto::{ProgressDto, StatusDto}, text_annotation_dto::{HpoAnnotationDto, ParentChildDto, TextAnnotationDto}}, hpo::{MinedCell, MiningConcept, ontology_loader}, phenoboard::HpoMatch};
18+
use crate::{dto::{pmid_dto::PmidDto, status_dto::{ProgressDto, StatusDto}, text_annotation_dto::{HpoAnnotationDto, ParentChildDto, TextAnnotationDto}}, hpo::{MinedCell, MiningConcept, ontology_loader}, phenoboard::HpoMatch, util::HgncBundle};
1919

2020
struct AppState {
2121
phenoboard: Mutex<PhenoboardSingleton>,
@@ -80,7 +80,8 @@ pub fn run() {
8080
create_cell_mappings,
8181
get_multi_hpo_strings,
8282
get_cohort_age_strings,
83-
get_status_dto
83+
get_status_dto,
84+
fetch_hgnc_data
8485
])
8586
.setup(|app| {
8687
let win = app.get_webview_window("main").unwrap();
@@ -1022,3 +1023,13 @@ async fn get_cohort_age_strings(
10221023
.map_err(|_| "Failed to acquire lock on HPO State".to_string())?;
10231024
singleton.get_all_cohort_age_strings(dto)
10241025
}
1026+
1027+
1028+
/// Get HGNC data related to a gene symbol
1029+
#[tauri::command]
1030+
async fn fetch_hgnc_data(
1031+
symbol: String
1032+
) -> Result<HgncBundle, String> {
1033+
util::fetch_hgnc_data(&symbol).await
1034+
}
1035+

src-tauri/src/util/hgnc_rest.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
2+
3+
use crate::util::HgncBundle;
4+
5+
6+
7+
pub async fn fetch_gene_data(symbol: &str) -> Result<HgncBundle, Box<dyn std::error::Error>> {
8+
let url = format!("https://rest.genenames.org/fetch/symbol/{}", symbol);
9+
println!("{}", url);
10+
let client = reqwest::Client::new();
11+
12+
let res = client
13+
.get(&url)
14+
.header(reqwest::header::ACCEPT, "application/json")
15+
.send()
16+
.await?;
17+
let body = res.text().await?;
18+
let bundle = parse_hgnc_json(&body)?;
19+
Ok(bundle)
20+
}
21+
22+
fn parse_hgnc_json(json_str: &str) -> Result<HgncBundle, Box<dyn std::error::Error>> {
23+
let v: serde_json::Value = serde_json::from_str(json_str)?;
24+
let num_found = v["response"]["numFound"]
25+
.as_u64()
26+
.ok_or("Could not find 'numFound' in response")?;
27+
28+
if num_found != 1 {
29+
return Err(format!("Expected 1 gene, found {}", num_found).into());
30+
}
31+
let doc = &v["response"]["docs"][0];
32+
if doc.is_null() {
33+
return Err("Response docs array is empty".into());
34+
}
35+
let hgnc_id = doc["hgnc_id"]
36+
.as_str()
37+
.ok_or("hgnc_id missing or not a string")?
38+
.to_string();
39+
let mane_select_array = doc["mane_select"]
40+
.as_array()
41+
.ok_or("mane_select missing or not an array")?;
42+
43+
let mane_select = mane_select_array
44+
.iter()
45+
.filter_map(|val| val.as_str())
46+
.find(|s| s.starts_with("NM_"))
47+
.ok_or("No RefSeq (NM_...) entry found in mane_select")?
48+
.to_string();
49+
50+
Ok(HgncBundle{ hgnc_id, mane_select})
51+
}
52+
53+
54+
55+
#[cfg(test)]
56+
mod tests {
57+
use super::*; // Assumes your logic is in the same crate
58+
59+
60+
61+
#[tokio::test]
62+
#[ignore = "API call"]
63+
async fn test_fetch_gene_data() {
64+
let symbol = "BRAF";
65+
let expected_hgnc = "HGNC:1097";
66+
let expected_mane = "NM_004333.6";
67+
let bundle = fetch_gene_data(symbol).await.expect("API call failed");
68+
assert_eq!(expected_hgnc, bundle.hgnc_id);
69+
assert_eq!(expected_mane, bundle.mane_select);
70+
71+
72+
}
73+
74+
#[test]
75+
fn test_parsing_logic() {
76+
let raw_json = r#"{"response":{"numFound":1,"docs":[{"hgnc_id":"HGNC:1097","mane_select":["ENST00000646891.2","NM_004333.6"]}]}}"#;
77+
let bundle = parse_hgnc_json(raw_json).unwrap();
78+
assert_eq!(bundle.hgnc_id, "HGNC:1097");
79+
assert_eq!(bundle.mane_select, "NM_004333.6");
80+
}
81+
82+
83+
}

src-tauri/src/util/mod.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
14
pub mod io_util;
25
pub mod pubmed_retrieval;
3-
pub mod text_to_annotation;
6+
pub mod text_to_annotation;
7+
mod hgnc_rest;
8+
9+
#[derive(Deserialize, Debug, Serialize, Clone)]
10+
#[serde(rename_all = "camelCase")]
11+
pub struct HgncBundle {
12+
hgnc_id: String,
13+
mane_select: String,
14+
}
15+
16+
pub async fn fetch_hgnc_data(symbol: &str) -> Result<HgncBundle, String> {
17+
let bundle = hgnc_rest::fetch_gene_data(symbol)
18+
.await
19+
.map_err(|e| e.to_string())?;
20+
Ok(bundle)
21+
}

src/app/cohortdialog/cohortdialog.component.html

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,26 @@ <h3>Gene Info</h3>
5959
<p class="error-text">Valid HGNC ID required (e.g., HGNC:1234)</p>
6060
}
6161
</div>
62-
62+
6363
<div class="form-field">
64-
<input placeholder="Gene symbol (e.g. CFTR)" formControlName="symbol" class="form-input" />
64+
<div class="form-field flex gap-2">
65+
<input
66+
placeholder="Gene symbol (e.g. CFTR)"
67+
formControlName="symbol"
68+
class="form-input flex-1" />
69+
<button
70+
type="button"
71+
[disabled]="!canFetch() || isLoading()"
72+
(click)="fetchAndFillHgnc()"
73+
class="px-3 py-1 bg-blue-50 text-blue-600 border border-blue-200 rounded hover:bg-blue-100 disabled:opacity-50 transition-all flex items-center gap-2"
74+
>
75+
@if (isLoading()) {
76+
<span class="animate-spin"></span>
77+
} @else {
78+
🔍 Fetch info
79+
}
80+
</button>
81+
</div>
6582
@if (form.get('symbol')?.invalid && form.get('symbol')?.touched) {
6683
<p class="error-text">Gene symbol is required.</p>
6784
}

src/app/cohortdialog/cohortdialog.component.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import { Component, inject, signal } from '@angular/core';
1+
import { Component, computed, inject, signal } from '@angular/core';
22
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
33
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
44
import { CommonModule } from '@angular/common';
55
import { FormsModule } from '@angular/forms';
66
import { noWhitespaceValidator, noLeadingTrailingSpacesValidator } from '../validators/validators';
77
import { MatMenuModule } from "@angular/material/menu";
88
import { HelpButtonComponent } from "../util/helpbutton/help-button.component";
9+
import { ConfigService } from '../services/config.service';
10+
import { toSignal } from '@angular/core/rxjs-interop';
911

1012

1113
@Component({
@@ -23,25 +25,28 @@ import { HelpButtonComponent } from "../util/helpbutton/help-button.component";
2325
styleUrls: ['./cohortdialog.component.css'],
2426
})
2527
export class CohortDialogComponent {
26-
form: FormGroup;
27-
28+
2829
showPasteArea = signal(false);
2930
pastedText = signal<string | null>(null);
3031
private fb = inject(FormBuilder);
3132
public dialogRef = inject(MatDialogRef<CohortDialogComponent>);
32-
public data = inject(MAT_DIALOG_DATA) as { title: string };
33-
constructor(
34-
) {
35-
this.form = this.fb.group({
33+
public data = inject(MAT_DIALOG_DATA) as { title: string };
34+
private configService = inject(ConfigService);
35+
36+
form: FormGroup = this.fb.group({
3637
diseaseId: ['', [Validators.required, Validators.pattern(/^OMIM:\d{6}$/)]],
3738
diseaseLabel: ['', [Validators.required, noLeadingTrailingSpacesValidator]],
3839
cohortAcronym: ['', [Validators.required, noWhitespaceValidator]],
3940
hgnc: ['', [Validators.required, Validators.pattern(/^HGNC:\d+$/)]],
4041
symbol: ['', [Validators.required, noWhitespaceValidator]],
4142
transcript: ['', [Validators.required, Validators.pattern(/^[\w]+\.\d+$/)]],
4243
});
43-
}
44-
44+
symbolValue = toSignal(this.form.get('symbol')!.valueChanges, { initialValue: '' });
45+
canFetch = computed(() => {
46+
const s = this.symbolValue();
47+
return s && s.trim().length > 0;
48+
});
49+
isLoading = signal(false);
4550

4651
cancel() {
4752
this.dialogRef.close(null);
@@ -85,4 +90,32 @@ public data = inject(MAT_DIALOG_DATA) as { title: string };
8590
togglePaste() {
8691
this.showPasteArea.update(v => !v);
8792
}
93+
94+
async fetchHgncData(symbol: string): Promise<{ hgncId: string, maneSelect: string } | null> {
95+
try {
96+
return await this.configService.fetchHgncData(symbol);
97+
} catch (error) {
98+
console.error(`Error fetching gene ${symbol}: ${error}`);
99+
return null; // Return null so your UI can show a "Gene not found" message
100+
}
101+
}
102+
103+
async fetchAndFillHgnc() {
104+
const symbol = this.symbolValue();
105+
if (!symbol) return;
106+
107+
this.isLoading.set(true);
108+
const result = await this.fetchHgncData(symbol);
109+
this.isLoading.set(false);
110+
111+
if (result) {
112+
this.form.patchValue({
113+
hgnc: result.hgncId,
114+
transcript: result.maneSelect
115+
});
116+
} else {
117+
// Optional: Toast or specific error handling if gene not found
118+
alert(`Could not find data for symbol: ${symbol}`);
119+
}
120+
}
88121
}

src/app/hpopolishing/hpopolishing.component.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,14 @@ import { AddageComponent } from '../addages/addage.component';
1111
import { MatDialog } from '@angular/material/dialog';
1212
import { NotificationService } from '../services/notification.service';
1313
import { HpoMatch } from '../models/hpo_mapping_result';
14-
import { MatIcon } from "@angular/material/icon";
1514

1615
/** This component takes the results of the raw text mining (fenominal) and allows the user to revise them and add new terms */
1716
@Component({
1817
selector: 'app-hpopolishing',
1918
templateUrl: './hpopolishing.component.html',
2019
styleUrls: ['./hpopolishing.component.scss'],
2120
standalone: true,
22-
imports: [CommonModule, FormsModule, HpoAutocompleteComponent, MatIcon]
21+
imports: [CommonModule, FormsModule, HpoAutocompleteComponent]
2322
})
2423
export class HpoPolishingComponent implements OnInit {
2524

src/app/services/config.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,10 @@ export class ConfigService {
374374
return await invoke<string[]>('get_all_cohort_age_strings', {dto: dto});
375375
}
376376

377+
async fetchHgncData(symbol: string): Promise<{ hgncId: string, maneSelect: string}> {
378+
return await invoke<{ hgncId: string, maneSelect: string}>('fetch_hgnc_data', {symbol: symbol});
379+
}
380+
377381
/**
378382
* Adjusts x and y coordinates to ensure a menu stays within the viewport.
379383
*/

0 commit comments

Comments
 (0)