use crate::{ReqError, make_agent};
const BASE_URL: &str = "https://www.rhea-db.org";
const CT_FILE_URL: &str = "https://ftp.expasy.org/databases/rhea/ctfiles";
const UNIPROT_URL: &str = "https://rest.uniprot.org/uniprotkb/search";
const UNIPROT_PAGE_SIZE: u32 = 500;
const USER_AGENT: &str = concat!(
"bio_apis/",
env!("CARGO_PKG_VERSION"),
" (https://github.com/David-OConnor/bio_apis)"
);
#[derive(Clone, Copy, PartialEq)]
pub enum Column {
RheaId,
Equation,
ChebiName,
ChebiId,
Ec,
Uniprot,
Go,
Pubmed,
XrefEcoCyc,
XrefMetaCyc,
XrefKegg,
XrefReactome,
XrefMCsa,
}
impl std::fmt::Display for Column {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let v = match self {
Self::RheaId => "rhea-id",
Self::Equation => "equation",
Self::ChebiName => "chebi",
Self::ChebiId => "chebi-id",
Self::Ec => "ec",
Self::Uniprot => "uniprot",
Self::Go => "go",
Self::Pubmed => "pubmed",
Self::XrefEcoCyc => "reaction-xref(EcoCyc)",
Self::XrefMetaCyc => "reaction-xref(MetaCyc)",
Self::XrefKegg => "reaction-xref(KEGG)",
Self::XrefReactome => "reaction-xref(Reactome)",
Self::XrefMCsa => "reaction-xref(M-CSA)",
};
write!(f, "{v}")
}
}
const REACTION_COLUMNS: [Column; 13] = [
Column::RheaId,
Column::Equation,
Column::ChebiName,
Column::ChebiId,
Column::Ec,
Column::Uniprot,
Column::Go,
Column::Pubmed,
Column::XrefKegg,
Column::XrefMetaCyc,
Column::XrefEcoCyc,
Column::XrefReactome,
Column::XrefMCsa,
];
#[derive(Clone, Copy, PartialEq)]
pub enum Direction {
LeftToRight,
RightToLeft,
}
impl Direction {
pub fn id(&self, master_id: u32) -> u32 {
match self {
Self::LeftToRight => master_id + 1,
Self::RightToLeft => master_id + 2,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "encode", derive(bincode::Encode, bincode::Decode))]
pub struct GoTerm {
pub id: String,
pub label: String,
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "encode", derive(bincode::Encode, bincode::Decode))]
pub struct Reaction {
pub id: u32,
pub equation: String,
pub participant_names: Vec<String>,
pub participant_chebi_ids: Vec<u32>,
pub ec_numbers: Vec<String>,
pub enzyme_count: u32,
pub go: Option<GoTerm>,
pub pubmed_ids: Vec<u32>,
pub kegg: Vec<String>,
pub metacyc: Vec<String>,
pub ecocyc: Vec<String>,
pub reactome: Vec<String>,
pub m_csa: Vec<String>,
}
impl Reaction {
pub fn accession(&self) -> String {
format!("RHEA:{}", self.id)
}
}
fn split_col(field: &str, prefix: &str) -> Vec<String> {
field
.split(';')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| v.strip_prefix(prefix).unwrap_or(v).to_owned())
.collect()
}
fn split_col_num(field: &str, prefix: &str) -> Vec<u32> {
field
.split(';')
.map(str::trim)
.filter_map(|v| v.strip_prefix(prefix))
.filter_map(|v| v.parse().ok())
.collect()
}
fn parse_reactions(tsv: &str) -> Result<Vec<Reaction>, ReqError> {
let mut result = Vec::new();
for line in tsv.lines().skip(1) {
if line.trim().is_empty() {
continue;
}
let cols: Vec<&str> = line.split('\t').collect();
if cols.len() < REACTION_COLUMNS.len() {
return Err(ReqError::Deserialize);
}
let id = cols[0]
.trim()
.strip_prefix("RHEA:")
.and_then(|v| v.parse().ok())
.ok_or(ReqError::Deserialize)?;
let go = cols[6].trim().split_once(' ').map(|(id, label)| GoTerm {
id: id.to_owned(),
label: label.to_owned(),
});
result.push(Reaction {
id,
equation: cols[1].trim().to_owned(),
participant_names: split_col(cols[2], ""),
participant_chebi_ids: split_col_num(cols[3], "CHEBI:"),
ec_numbers: split_col(cols[4], "EC:"),
enzyme_count: cols[5].trim().parse().unwrap_or_default(),
go,
pubmed_ids: split_col_num(cols[7], ""),
kegg: split_col(cols[8], "KEGG:"),
metacyc: split_col(cols[9], "MetaCyc:"),
ecocyc: split_col(cols[10], "EcoCyc:"),
reactome: split_col(cols[11], "Reactome:"),
m_csa: split_col(cols[12], "M-CSA:"),
});
}
Ok(result)
}
fn request(url: &str) -> Result<ureq::http::Response<ureq::Body>, ReqError> {
let agent = make_agent();
Ok(agent
.get(url)
.header("User-Agent", USER_AGENT)
.header("Accept-Encoding", "identity")
.call()?)
}
fn get(url: &str) -> Result<String, ReqError> {
let mut resp = request(url)?;
if resp.status() != 200 {
return Err(ReqError::Http);
}
Ok(resp.body_mut().read_to_string()?)
}
pub fn open_overview(id: u32) {
if let Err(e) = webbrowser::open(&format!("{BASE_URL}/rhea/{id}")) {
eprintln!("Failed to open the web browser: {:?}", e);
}
}
pub fn query_table(
query: &str,
columns: &[Column],
limit: Option<u32>,
) -> Result<String, ReqError> {
let cols: Vec<String> = columns.iter().map(|c| c.to_string()).collect();
let mut params = url::form_urlencoded::Serializer::new(String::new());
params.append_pair("query", query);
params.append_pair("columns", &cols.join(","));
params.append_pair("format", "tsv");
if let Some(l) = limit {
params.append_pair("limit", &l.to_string());
}
get(&format!("{BASE_URL}/rhea/?{}", params.finish()))
}
pub fn search(query: &str, limit: Option<u32>) -> Result<Vec<Reaction>, ReqError> {
parse_reactions(&query_table(query, &REACTION_COLUMNS, limit)?)
}
pub fn find_ids_from_search(query: &str, limit: Option<u32>) -> Result<Vec<u32>, ReqError> {
let tsv = query_table(query, &[Column::RheaId], limit)?;
Ok(tsv
.lines()
.skip(1)
.filter_map(|l| l.trim().strip_prefix("RHEA:"))
.filter_map(|v| v.parse().ok())
.collect())
}
pub fn load_reaction(id: u32) -> Result<Reaction, ReqError> {
search(&format!("rhea:{id}"), Some(1))?
.into_iter()
.next()
.ok_or(ReqError::Deserialize)
}
pub fn reactions_from_chebi(chebi_id: u32, limit: Option<u32>) -> Result<Vec<Reaction>, ReqError> {
search(&format!("chebi:{chebi_id}"), limit)
}
pub fn reactions_from_ec(ec: &str, limit: Option<u32>) -> Result<Vec<Reaction>, ReqError> {
search(&format!("ec:{}", ec.trim_start_matches("EC:")), limit)
}
pub fn reactions_from_uniprot(
accession: &str,
limit: Option<u32>,
) -> Result<Vec<Reaction>, ReqError> {
search(&format!("uniprot:{accession}"), limit)
}
fn ct_file_url(id: u32, ext: &str) -> String {
format!("{CT_FILE_URL}/{ext}/{id}.{ext}")
}
pub fn load_rxn(master_id: u32, direction: Direction) -> Result<String, ReqError> {
get(&ct_file_url(direction.id(master_id), "rxn"))
}
pub fn load_rd(master_id: u32, direction: Direction) -> Result<String, ReqError> {
get(&ct_file_url(direction.id(master_id), "rd"))
}
fn parse_next_link(header: &str) -> Option<String> {
if !header.contains("rel=\"next\"") {
return None;
}
let start = header.find('<')? + 1;
let end = header.find('>')?;
Some(header[start..end].to_owned())
}
pub fn uniprot_ids(
master_id: u32,
reviewed_only: bool,
limit: Option<u32>,
) -> Result<Vec<String>, ReqError> {
let mut query = format!("(cc_catalytic_activity:\"rhea:{master_id}\")");
if reviewed_only {
query += " AND (reviewed:true)";
}
let page_size = limit.unwrap_or(UNIPROT_PAGE_SIZE).min(UNIPROT_PAGE_SIZE);
let mut params = url::form_urlencoded::Serializer::new(String::new());
params.append_pair("query", &query);
params.append_pair("fields", "accession");
params.append_pair("format", "tsv");
params.append_pair("size", &page_size.to_string());
let mut url = Some(format!("{UNIPROT_URL}?{}", params.finish()));
let mut result = Vec::new();
while let Some(u) = url {
let mut resp = request(&u)?;
if resp.status() != 200 {
return Err(ReqError::Http);
}
url = resp
.headers()
.get("link")
.and_then(|v| v.to_str().ok())
.and_then(parse_next_link);
let tsv = resp.body_mut().read_to_string()?;
let accessions: Vec<String> = tsv
.lines()
.skip(1)
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect();
if accessions.is_empty() {
break;
}
result.extend(accessions);
if let Some(l) = limit
&& result.len() >= l as usize
{
result.truncate(l as usize);
break;
}
}
Ok(result)
}