Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,22 @@ The resolver service generates new Ensembl website urls for different features b

http://localhost:8001/id/ENSG00000127720.3

### Running application in Local

From the project root directory run the following commands

`$ mv sample-env .env`

`$ python3 -m venv venv`

`$ source venv/bin/activate`

`$ pip install -r requirements.txt`

`$ python3 -m uvicorn app.main:app --port 8001 --reload`

### Run unit tests:
```
cd app
python -m unittest tests.test_resolver
python -m unittest tests.test_rapid
```
File renamed without changes.
Empty file added app/api/__init__.py
Empty file.
65 changes: 45 additions & 20 deletions app/api/models/resolver.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from enum import Enum
from typing import Optional, Literal, List, Dict, Annotated
from pydantic import BaseModel, Field
from typing import Literal
from pydantic import BaseModel, Field, ConfigDict


class SearchPayload(BaseModel):
stable_id: str = Field(default=None, title="Stable ID of a gene")
type: Optional[Literal["gene"]] = Field(
type: Literal["gene"] | None = Field(
default=None, title="Type of stable id, e.g. gene"
)
per_page: int = 1
app: Optional[Literal["genome-browser", "entity-viewer"]] = Field(
app: Literal["genome-browser", "entity-viewer"] = Field(
default="entity-viewer", title="Preferred app to be redirected to"
)

Expand All @@ -20,7 +20,7 @@ class SearchMatch(BaseModel):


class SearchResult(BaseModel):
matches: List[SearchMatch] = []
matches: list[SearchMatch]


class Assembly(BaseModel):
Expand All @@ -29,10 +29,10 @@ class Assembly(BaseModel):


class MetadataResult(BaseModel):
assembly: Assembly
scientific_name: str
common_name: str
type: Optional[Dict[str, str]] = None
assembly: Assembly | None = None
scientific_name: str | None = None
common_name: str | None = None
type: dict[str, str] | None = None
is_reference: bool = False


Expand All @@ -47,16 +47,41 @@ class RapidResolverHtmlResponseType(str, Enum):
HELP = "HELP"
INFO = "INFO"

# Exclude all fields except resolved_url in JSON response.

class RapidResolverResponse(BaseModel):
resolved_url: str
response_type: Annotated[Optional[RapidResolverHtmlResponseType], Field(exclude=True)] = None
code: Annotated[Optional[int], Field(exclude=True)] = None
species_name: Annotated[Optional[str], Field(exclude=True)] = None
gene_id: Annotated[Optional[str], Field(exclude=True)] = None
location: Annotated[Optional[str], Field(exclude=True)] = None
message: Annotated[Optional[str], Field(exclude=True)] = None
rapid_archive_url: Annotated[Optional[str], Field(exclude=True)] = None

class Config:
use_enum_values = True
response_type: RapidResolverHtmlResponseType | None = None
code: int | None = None
species_name: str | None = None
gene_id: str | None = None
location: str | None = None
message: str | None = None
rapid_archive_url: str | None = None

model_config = ConfigDict(use_enum_values=True)

_excluded_fields = {
"response_type", "code", "species_name", "gene_id",
"location", "message", "rapid_archive_url"
}

def model_dump(self, *args, **kwargs):
kwargs.setdefault("exclude", self._excluded_fields)
return super().model_dump(*args, **kwargs)

def model_dump_json(self, *args, **kwargs):
kwargs.setdefault("exclude", self._excluded_fields)
return super().model_dump_json(*args, **kwargs)


class StableIdResolverContent(MetadataResult):
entity_viewer_url: str | None = None
genome_browser_url: str | None = None


class StableIdResolverResponse(BaseModel):
stable_id: str
code: int | None = None
message: str | None = None
rapid_archive_url: str | None = None
content: list[StableIdResolverContent] | None = None
94 changes: 59 additions & 35 deletions app/api/resources/rapid_view.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,71 @@
import os
from urllib.parse import parse_qs

from dotenv import load_dotenv
from fastapi import APIRouter, Request, Query, HTTPException

from jinja2 import Environment, FileSystemLoader
from starlette.responses import HTMLResponse

import logging
from api.models.resolver import RapidResolverResponse, RapidResolverHtmlResponseType
from core.logging import InterceptHandler
from core.config import ENSEMBL_URL
from api.utils.metadata import get_genome_id_from_assembly_accession_id
from api.utils.rapid import construct_url, format_assembly_accession, construct_rapid_archive_url

from app.api.error_response import response_error_handler
from app.api.models.resolver import RapidResolverResponse, RapidResolverHtmlResponseType, SearchPayload, \
StableIdResolverResponse
from app.api.utils.commons import build_stable_id_resolver_content, is_json_request
from app.api.utils.metadata import get_genome_id_from_assembly_accession_id, get_metadata
from app.api.utils.rapid import format_assembly_accession, construct_rapid_archive_url, construct_url, \
generate_rapid_id_page, generate_rapid_page
from app.api.utils.search import get_search_results
from app.core.config import ENSEMBL_URL, RAPID_ARCHIVE_URL
from app.core.logging import InterceptHandler

logging.getLogger().handlers = [InterceptHandler()]

router = APIRouter()


@router.get("/id/{stable_id}", name="Resolve rapid stable ID")
async def resolve_rapid_stable_id(request: Request, stable_id: str):
# Handle only gene stable id for now
params = SearchPayload(stable_id=stable_id, type="gene", per_page=10)
search_results = get_search_results(params)
rapid_archive_url = f"{RAPID_ARCHIVE_URL}/id/{stable_id}"

if not search_results or not search_results.get("matches"):
if is_json_request(request):
return response_error_handler({"status": 404})
res = StableIdResolverResponse(
stable_id=stable_id,
code=404,
message="No results",
content=None,
rapid_archive_url=rapid_archive_url
)
return HTMLResponse(generate_rapid_id_page(res))
Copy link

@veidenberg veidenberg Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you mean to pass a model or dict to generate_rapid_id_page()? For dict use res.model_dump() (like you do on line 58 below).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not important for html response.. Changed to return object

For Json response its important to ignore certain fields


matches = search_results.get("matches")
metadata_results = get_metadata(matches)

stable_id_resolver_response = StableIdResolverResponse(
stable_id=stable_id,
code=308,
rapid_archive_url=rapid_archive_url
)
results = build_stable_id_resolver_content(metadata_results)
stable_id_resolver_response.content = results

if is_json_request(request):
return results

return HTMLResponse(generate_rapid_id_page(stable_id_resolver_response))


@router.get("/info/{subpath:path}", name="Resolve rapid help page")
async def resolve_rapid_help(request: Request, subpath: str = ""):
response = RapidResolverResponse(
response_type=RapidResolverHtmlResponseType.HELP,
code=308,
resolved_url=f"{ENSEMBL_URL}/help",
)
return resolved_response(response, request)
return rapid_resolved_response(response, request)


@router.get("/Multi/Tools/Blast", name="Resolve rapid blast page")
Expand All @@ -36,7 +75,7 @@ async def resolve_rapid_blast(request: Request):
code=308,
resolved_url=f"{ENSEMBL_URL}/blast",
)
return resolved_response(response, request)
return rapid_resolved_response(response, request)


# Resolve rapid urls
Expand All @@ -53,7 +92,7 @@ async def resolve_species(
resolved_url=f"{ENSEMBL_URL}/blast",
species_name=species_url_name,
)
return resolved_response(response, request)
return rapid_resolved_response(response, request)

assembly_accession_id = format_assembly_accession(species_url_name)

Expand All @@ -65,7 +104,7 @@ async def resolve_species(
message="Invalid input accession ID",
species_name=species_url_name,
)
return resolved_response(input_error_response, request)
return rapid_resolved_response(input_error_response, request)

try:
genome_object = get_genome_id_from_assembly_accession_id(assembly_accession_id)
Expand All @@ -88,7 +127,7 @@ async def resolve_species(
location=query_params.get("r", [None])[0],
rapid_archive_url=rapid_archive_url,
)
return resolved_response(response, request)
return rapid_resolved_response(response, request)
else:
raise HTTPException(status_code=404, detail="Genome not found")
except HTTPException as e:
Expand All @@ -100,7 +139,7 @@ async def resolve_species(
message=e.detail,
species_name=species_url_name,
)
return resolved_response(response, request)
return rapid_resolved_response(response, request)
except Exception as e:
logging.debug(f"Unexpected error occurred: {e}")
response = RapidResolverResponse(
Expand All @@ -110,7 +149,7 @@ async def resolve_species(
resolved_url=f"{ENSEMBL_URL}/species-selector",
message=str(e),
)
return resolved_response(response, request)
return rapid_resolved_response(response, request)


@router.get("/", name="Rapid Home")
Expand All @@ -120,30 +159,15 @@ async def resolve_home(request: Request):
code=308,
resolved_url=ENSEMBL_URL,
)
return resolved_response(response, request)
return rapid_resolved_response(response, request)


def resolved_response(response: RapidResolverResponse, request: Request):
# Return JSON response if requested
if "application/json" in request.headers.get("accept"):
# Handle error responses for JSON requests
def rapid_resolved_response(response: RapidResolverResponse, request: Request):
if is_json_request(request):
if response.response_type == RapidResolverHtmlResponseType.ERROR:
raise HTTPException(
status_code=response.code,
detail=response.message or "An error occurred",
)
# Doesn't raise redirect for JSON requests, just return the URL. Because swagger UI doesn't handle redirects well.
# So code is always 200 for successful JSON response.
return response

# Default to HTML response
return HTMLResponse(generate_html_content(response))


def generate_html_content(response):
load_dotenv()
CURR_DIR = os.path.dirname(os.path.abspath(__file__))
env = Environment(loader=FileSystemLoader(os.path.join(CURR_DIR, "templates/rapid")))
rapid_redirect_page_template = env.get_template("main.html")
rapid_redirect_page_html = rapid_redirect_page_template.render(response=response)
return rapid_redirect_page_html
return response.model_dump()
return HTMLResponse(generate_rapid_page(response))
80 changes: 34 additions & 46 deletions app/api/resources/resolver_view.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import os
from fastapi import APIRouter, Request
from typing import Optional, Literal, List
from typing import Optional, Literal
from fastapi.responses import RedirectResponse, HTMLResponse
import logging
from dotenv import load_dotenv
from jinja2 import Environment, FileSystemLoader
from core.logging import InterceptHandler
from api.models.resolver import SearchPayload, ResolvedPayload
from api.error_response import response_error_handler
from core.config import DEFAULT_APP, ENSEMBL_URL
from api.utils.metadata import get_metadata
from api.utils.search import get_search_results

from app.api.error_response import response_error_handler
from app.api.models.resolver import SearchPayload, StableIdResolverResponse
from app.api.utils.commons import build_stable_id_resolver_content, is_json_request
from app.api.utils.metadata import get_metadata
from app.api.utils.resolver import generate_resolver_id_page
from app.api.utils.search import get_search_results
from app.core.config import DEFAULT_APP
from app.core.logging import InterceptHandler

logging.getLogger().handlers = [InterceptHandler()]

Expand All @@ -27,52 +27,40 @@ async def resolve(
):

params = SearchPayload(stable_id=stable_id, type=type, per_page=10)

# Get genome_ids from search api
search_results = get_search_results(params)

if not search_results:
return response_error_handler({"status": 404})
if not search_results or not search_results.get("matches"):
if is_json_request(request):
return response_error_handler({"status": 404})

res = StableIdResolverResponse(
stable_id=stable_id,
code=404,
message="No results",
content=None
)
return HTMLResponse(generate_resolver_id_page(res))

matches = search_results.get("matches")
if not matches:
return response_error_handler({"status": 404})

# Get metadata for all genomes
metadata_results = get_metadata(matches)

results: List[ResolvedPayload] = []

for genome_id in metadata_results:

metadata = metadata_results[genome_id]

if not metadata:
continue

if app == "entity-viewer":
url = f"{ENSEMBL_URL}/{app}/{genome_id}/{type}:{metadata['unversioned_stable_id']}"
else:
url = f"{ENSEMBL_URL}/{app}/{genome_id}?focus={type}:{metadata['unversioned_stable_id']}"
stable_id_resolver_response = StableIdResolverResponse(
stable_id=stable_id,
code=308,
)
results = build_stable_id_resolver_content(metadata_results)
stable_id_resolver_response.content = results

metadata["resolved_url"] = url
resolved_payload = ResolvedPayload(**metadata)
results.append(resolved_payload.model_dump())

if "application/json" in request.headers.get("accept"):
if is_json_request(request):
return results

if len(results) == 1:
return RedirectResponse(results[0]["resolved_url"])
if app == "entity-viewer":
resolved_url = results[0].entity_viewer_url
else:
resolved_url = results[0].genome_browser_url
return RedirectResponse(resolved_url)
else:
return HTMLResponse(generate_html_content(results))


def generate_html_content(results):
# Create a simple HTML page with a list of URLs
load_dotenv()
CURR_DIR = os.path.dirname(os.path.abspath(__file__))
env = Environment(loader=FileSystemLoader(os.path.join(CURR_DIR, "templates")))
search_results_template = env.get_template("search_results.html")
search_results_html = search_results_template.render(results=results)
return search_results_html
return HTMLResponse(generate_resolver_id_page(stable_id_resolver_response))
Loading