diff --git a/README.md b/README.md index 3878d1d..93f8f09 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/app/tests/__init__.py b/app/__init__.py similarity index 100% rename from app/tests/__init__.py rename to app/__init__.py diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/models/resolver.py b/app/api/models/resolver.py index 82e389c..844e1e7 100644 --- a/app/api/models/resolver.py +++ b/app/api/models/resolver.py @@ -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" ) @@ -20,7 +20,7 @@ class SearchMatch(BaseModel): class SearchResult(BaseModel): - matches: List[SearchMatch] = [] + matches: list[SearchMatch] class Assembly(BaseModel): @@ -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 @@ -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 diff --git a/app/api/resources/rapid_view.py b/app/api/resources/rapid_view.py index af3c846..aa1cb0f 100644 --- a/app/api/resources/rapid_view.py +++ b/app/api/resources/rapid_view.py @@ -1,24 +1,63 @@ -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)) + + 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( @@ -26,7 +65,7 @@ async def resolve_rapid_help(request: Request, subpath: str = ""): 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") @@ -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 @@ -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) @@ -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) @@ -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: @@ -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( @@ -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") @@ -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)) diff --git a/app/api/resources/resolver_view.py b/app/api/resources/resolver_view.py index 996ab9b..9237dac 100644 --- a/app/api/resources/resolver_view.py +++ b/app/api/resources/resolver_view.py @@ -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()] @@ -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)) diff --git a/app/api/resources/routes.py b/app/api/resources/routes.py index acfdb57..f153cce 100644 --- a/app/api/resources/routes.py +++ b/app/api/resources/routes.py @@ -17,7 +17,7 @@ from fastapi import APIRouter -from api.resources import resolver_view, rapid_view +from app.api.resources import resolver_view, rapid_view router = APIRouter() diff --git a/app/api/resources/templates/rapid/rapid.html b/app/api/resources/templates/rapid/rapid.html new file mode 100644 index 0000000..42a29da --- /dev/null +++ b/app/api/resources/templates/rapid/rapid.html @@ -0,0 +1,16 @@ +{% extends "shared/base.html" %} + +{% block title %}Ensembl Rapid Resolver{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +{% include "rapid/rapid_content.html" %} +{% endblock %} + +{% block footer %} +{% include "rapid/footer.html" %} +{% endblock %} diff --git a/app/api/resources/templates/rapid/content.html b/app/api/resources/templates/rapid/rapid_content.html similarity index 80% rename from app/api/resources/templates/rapid/content.html rename to app/api/resources/templates/rapid/rapid_content.html index 40ffdc0..d979f20 100644 --- a/app/api/resources/templates/rapid/content.html +++ b/app/api/resources/templates/rapid/rapid_content.html @@ -1,4 +1,4 @@ -{% import "url.html" as url_helper %} +{% import "shared/url.html" as url_helper %}
@@ -8,7 +8,7 @@ Species {{ response.species_name }}
{% endif %} - {{ url_helper.render_url(response.resolved_url) }} + {{ url_helper.render_url("Redirecting to", response.resolved_url) }} {% elif response.response_type == "INFO" %}
Species {{ response.species_name }} @@ -23,7 +23,7 @@ Location {{ response.location }}
{% endif %} - {{ url_helper.render_url(response.resolved_url) }} + {{ url_helper.render_url("Redirecting to", response.resolved_url) }} {% elif response.response_type == "ERROR" %}
Species {{ response.species_name }} @@ -31,9 +31,9 @@
{{ response.message }}
- {{ url_helper.render_url(response.resolved_url) }} + {{ url_helper.render_url("Redirecting to", response.resolved_url) }} {% else %} - {{ url_helper.render_url("https://beta.ensembl.org/") }} + {{ url_helper.render_url("Redirecting to", "https://beta.ensembl.org/") }} {% endif %}
diff --git a/app/api/resources/templates/rapid/rapid_id.html b/app/api/resources/templates/rapid/rapid_id.html new file mode 100644 index 0000000..e74a38c --- /dev/null +++ b/app/api/resources/templates/rapid/rapid_id.html @@ -0,0 +1,21 @@ +{% extends "shared/base.html" %} + +{% block title %}Ensembl Rapid Stable ID Resolver{% endblock %} + +{% block extra_head %} + {% if response.content and response.content|length == 1 %} + + {% endif %} +{% endblock %} + +{% block content %} +{% include "shared/id_content.html" %} +{% endblock %} + +{% block footer %} +{% include "rapid/footer.html" %} +{% endblock %} + +{% block script %} + +{% endblock %} \ No newline at end of file diff --git a/app/api/resources/templates/resolver/resolver_id.html b/app/api/resources/templates/resolver/resolver_id.html new file mode 100644 index 0000000..a339a83 --- /dev/null +++ b/app/api/resources/templates/resolver/resolver_id.html @@ -0,0 +1,11 @@ +{% extends "shared/base.html" %} + +{% block title %}Ensembl Stable ID Resolver{% endblock %} + +{% block content %} +{% include "shared/id_content.html" %} +{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/app/api/resources/templates/search_results.html b/app/api/resources/templates/search_results.html deleted file mode 100644 index ca0381f..0000000 --- a/app/api/resources/templates/search_results.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - Ensembl Resolver - - -

Resolved URLs

- - - - - - - - - - {% for item in results %} - - - - - - - - - {% endfor %} -
Assembly NameAccession IDScientific nameCommon nameTypeResolved URL
{{ item.assembly.name }}{{ item.assembly.accession_id }}{{ item.scientific_name }}{{ item.common_name }} - {% if item.type and item.is_reference %} - {{ item.type.kind }} - {{ item.type.value }}, Reference - {% elif item.is_reference %} - Reference - {% elif item.type %} - {{ item.type.kind }} - {{ item.type.value }} - {% else %} - - - {% endif %} - - Ensembl -
- - diff --git a/app/api/resources/templates/shared/_entity_viewer_icon.html b/app/api/resources/templates/shared/_entity_viewer_icon.html new file mode 100644 index 0000000..94da5a1 --- /dev/null +++ b/app/api/resources/templates/shared/_entity_viewer_icon.html @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/app/api/resources/templates/shared/_genome_browser_icon.html b/app/api/resources/templates/shared/_genome_browser_icon.html new file mode 100644 index 0000000..77d7797 --- /dev/null +++ b/app/api/resources/templates/shared/_genome_browser_icon.html @@ -0,0 +1,18 @@ + + + + \ No newline at end of file diff --git a/app/api/resources/templates/rapid/_home_icon.html b/app/api/resources/templates/shared/_home_icon.html similarity index 100% rename from app/api/resources/templates/rapid/_home_icon.html rename to app/api/resources/templates/shared/_home_icon.html diff --git a/app/api/resources/templates/rapid/_logo.html b/app/api/resources/templates/shared/_logo.html similarity index 100% rename from app/api/resources/templates/rapid/_logo.html rename to app/api/resources/templates/shared/_logo.html diff --git a/app/api/resources/templates/rapid/appbar.html b/app/api/resources/templates/shared/appbar.html similarity index 77% rename from app/api/resources/templates/rapid/appbar.html rename to app/api/resources/templates/shared/appbar.html index 50f8336..da6b3cc 100644 --- a/app/api/resources/templates/rapid/appbar.html +++ b/app/api/resources/templates/shared/appbar.html @@ -1,12 +1,12 @@
- {% include "_logo.html" %} + {% include "shared/_logo.html" %}
diff --git a/app/api/resources/templates/rapid/main.html b/app/api/resources/templates/shared/base.html similarity index 55% rename from app/api/resources/templates/rapid/main.html rename to app/api/resources/templates/shared/base.html index 27128d7..ddfe4c0 100644 --- a/app/api/resources/templates/rapid/main.html +++ b/app/api/resources/templates/shared/base.html @@ -1,26 +1,30 @@ - Ensembl Rapid Resolver + {% block title %}Ensembl Resolver{% endblock %} - - + + + - + + + {% block extra_head %}{% endblock %} - {% include "appbar.html" %} + {% include "shared/appbar.html" %}
- {% include "venn.html" %} - {% include "content.html" %} - {% include "footer.html" %} + {% include "shared/venn.html" %} + {% block content %}{% endblock %} + {% block footer %}{% endblock %}
+ {% block script %}{% endblock %} diff --git a/app/api/resources/templates/shared/id_content.html b/app/api/resources/templates/shared/id_content.html new file mode 100644 index 0000000..c101bcf --- /dev/null +++ b/app/api/resources/templates/shared/id_content.html @@ -0,0 +1,63 @@ +{% import "shared/url.html" as url_helper %} + + diff --git a/app/api/resources/templates/rapid/url.html b/app/api/resources/templates/shared/url.html similarity index 53% rename from app/api/resources/templates/rapid/url.html rename to app/api/resources/templates/shared/url.html index f91f4fb..3ed9f99 100644 --- a/app/api/resources/templates/rapid/url.html +++ b/app/api/resources/templates/shared/url.html @@ -1,6 +1,6 @@ -{% macro render_url(url) %} +{% macro render_url(title, url) %}
- Redirecting to + {{ title }} {{ url }} diff --git a/app/api/resources/templates/rapid/venn.html b/app/api/resources/templates/shared/venn.html similarity index 100% rename from app/api/resources/templates/rapid/venn.html rename to app/api/resources/templates/shared/venn.html diff --git a/app/api/utils/__init__.py b/app/api/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/utils/commons.py b/app/api/utils/commons.py new file mode 100644 index 0000000..4cbad3e --- /dev/null +++ b/app/api/utils/commons.py @@ -0,0 +1,33 @@ +from app.api.models.resolver import StableIdResolverContent +from app.core.config import ENSEMBL_URL + + +def build_stable_id_resolver_content(metadata_results) -> list[StableIdResolverContent]: + results: list[StableIdResolverContent] = [] + + for genome_id in metadata_results: + metadata = metadata_results[genome_id] + + if not metadata: + continue + + content = StableIdResolverContent( + entity_viewer_url=build_entity_viewer_url(genome_id, metadata['unversioned_stable_id']), + genome_browser_url=build_genome_browser_url(genome_id, metadata['unversioned_stable_id']), + **metadata, + ) + results.append(content) + + return results + + +def build_entity_viewer_url(genome_id: str, stable_id: str) -> str: + return f"{ENSEMBL_URL}/entity-viewer/{genome_id}/gene:{stable_id}" + + +def build_genome_browser_url(genome_id: str, stable_id: str) -> str: + return f"{ENSEMBL_URL}/genome-browser/{genome_id}?focus=gene:{stable_id}" + + +def is_json_request(request) -> bool: + return "application/json" in request.headers.get("accept") diff --git a/app/api/utils/metadata.py b/app/api/utils/metadata.py index 059a9d4..e739f84 100644 --- a/app/api/utils/metadata.py +++ b/app/api/utils/metadata.py @@ -1,8 +1,9 @@ from loguru import logger import requests from typing import List -from core.config import ENSEMBL_URL -from api.models.resolver import SearchMatch + +from app.api.models.resolver import SearchMatch +from app.core.config import ENSEMBL_URL def get_metadata(matches: List[SearchMatch] = []): diff --git a/app/api/utils/rapid.py b/app/api/utils/rapid.py index 56f0507..465aa2e 100644 --- a/app/api/utils/rapid.py +++ b/app/api/utils/rapid.py @@ -1,8 +1,12 @@ +import os + +from jinja2 import Environment, FileSystemLoader from loguru import logger import requests -from core.config import ENSEMBL_URL, NCBI_DATASETS_URL, RAPID_ARCHIVE_URL import re +from app.core.config import NCBI_DATASETS_URL, ENSEMBL_URL, RAPID_ARCHIVE_URL + def get_assembly_accession_from_ncbi(accession_id: str): try: @@ -109,3 +113,18 @@ def construct_rapid_archive_url(species_url_name, subpath, query_params): return url + +def generate_html_content(response, page): + templates_path = os.path.join(os.path.dirname(__file__), "../resources/templates") + env = Environment(loader=FileSystemLoader(templates_path)) + template = env.get_template(f"rapid/{page}") + content = template.render(response=response) + return content + + +def generate_rapid_page(response): + return generate_html_content(response, "rapid.html") + + +def generate_rapid_id_page(response): + return generate_html_content(response, "rapid_id.html") diff --git a/app/api/utils/resolver.py b/app/api/utils/resolver.py new file mode 100644 index 0000000..959e553 --- /dev/null +++ b/app/api/utils/resolver.py @@ -0,0 +1,15 @@ +import os + +from jinja2 import FileSystemLoader, Environment + + +def generate_html_content(response, page): + templates_path = os.path.join(os.path.dirname(__file__), "../resources/templates") + env = Environment(loader=FileSystemLoader(templates_path)) + template = env.get_template(f"resolver/{page}") + content = template.render(response=response) + return content + + +def generate_resolver_id_page(response): + return generate_html_content(response, "resolver_id.html") diff --git a/app/api/utils/search.py b/app/api/utils/search.py index 00032ab..150df8f 100644 --- a/app/api/utils/search.py +++ b/app/api/utils/search.py @@ -1,7 +1,8 @@ import requests from loguru import logger -from api.models.resolver import SearchPayload -from core.config import ENSEMBL_SEARCH_HUB_API + +from app.api.models.resolver import SearchPayload +from app.core.config import ENSEMBL_SEARCH_HUB_API def get_search_results(params: SearchPayload): diff --git a/app/main.py b/app/main.py index 433b202..9635da5 100644 --- a/app/main.py +++ b/app/main.py @@ -14,14 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. """ +import os.path from fastapi import FastAPI from starlette.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.openapi.docs import get_swagger_ui_html -from api.resources.routes import router -from core.config import API_PREFIX, ALLOWED_HOSTS, VERSION, PROJECT_NAME, DEBUG +from app.api.resources.routes import router +from app.core.config import PROJECT_NAME, DEBUG, VERSION, ALLOWED_HOSTS, API_PREFIX def get_application() -> FastAPI: @@ -48,7 +49,8 @@ def get_application() -> FastAPI: app = get_application() -app.mount("/static", StaticFiles(directory="static"), name="static_files") +static_files_path = os.path.join(os.path.dirname(__file__), "./static") +app.mount("/static", StaticFiles(directory=static_files_path), name="static_files") @app.get("/", include_in_schema=False) diff --git a/app/static/APISpecification.yaml b/app/static/APISpecification.yaml index ebe8c1d..b6eda8a 100644 --- a/app/static/APISpecification.yaml +++ b/app/static/APISpecification.yaml @@ -120,6 +120,67 @@ paths: $ref: '#/components/responses/404' '500': $ref: '#/components/responses/500' + /rapid/id/{stable_id}: + get: + tags: + - Rapid Resolver + summary: Resolve Rapid stable ID + description: Resolves to a beta url when a stable id + parameters: + - name: stable_id + in: path + description: Gene stable id used to represent a gene in a genome. + required: true + schema: + type: string + example: ENSG00000221914.11 + responses: + '200': + description: OK + headers: + Location: + schema: + type: string + content: + application/json: + schema: + type: array + items: + type: object + properties: + assembly: + type: object + properties: + name: + type: string + description: Assembly name + accession_id: + type: string + description: Assembly accession id + scientific_name: + type: string + description: Species scientific name + common_name: + type: string + description: Species common name + type: + type: string + description: Genome type + entity_viewer_url: + type: string + description: Resolved Ensembl entity viewer url + genome_browser_url: + type: string + description: Resolved Ensembl genome browser url + is_reference: + type: boolean + description: Whether a genome is reference or not + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' /rapid/Multi/Tools/Blast: get: summary: Resolve rapid site blast url diff --git a/app/static/css/search_results.css b/app/static/css/search_results.css deleted file mode 100644 index 33fd69e..0000000 --- a/app/static/css/search_results.css +++ /dev/null @@ -1,9 +0,0 @@ -table { - border: 1; - border-collapse: collapse; -} - -td, th { - min-width: 150px; - text-align: left; -} \ No newline at end of file diff --git a/app/static/css/rapid_page.css b/app/static/css/styles.css similarity index 62% rename from app/static/css/rapid_page.css rename to app/static/css/styles.css index 452d1e9..5ace665 100644 --- a/app/static/css/rapid_page.css +++ b/app/static/css/styles.css @@ -1,10 +1,11 @@ :root { --standard-gutter: 30px; --global-padding-left: calc(var(--standard-gutter) * 4); - --color-light-blue: #33adff; + --color-blue: #0099ff; --font-weight-light: 300; --color-medium-dark-grey: #9aa7b1; --color-red: #d90000; + --color-black: #1b2c39; --color-medium-light-grey: #d4d9de; } @@ -13,6 +14,7 @@ body { margin: 0; padding: 0; height: 100%; + min-width: 1024px; /* Limit Desktop */ font-family: Lato, 'Helvetica Neue', Helvetica, Arial, sans-serif; } @@ -58,7 +60,7 @@ body { } .copyright a { - color: var(--color-light-blue); + color: var(--color-blue); text-decoration: none; } @@ -83,7 +85,12 @@ body { .redirect-content { margin-top: 30px; text-align: center; - width: 350px; + width: 50%; +} + +.id-redirect-content { + margin-top: 30px; + text-align: center; } .redirect-text { @@ -93,21 +100,21 @@ body { } .footer { - font-size: 12px; - color: var(--color-medium-dark-grey); - margin-top: 50px; - width: 300px; - text-align: center; + font-size: 12px; + color: var(--color-medium-dark-grey); + margin-top: 50px; + width: 300px; + text-align: center; } .footer-link a { font-size: 12px; - color: var(--color-light-blue); + color: var(--color-blue); text-decoration: none; } .link { - color: var(--color-light-blue); + color: var(--color-blue); text-decoration: none; font-size: 13px; } @@ -115,6 +122,7 @@ body { .error-message { color: var(--color-red); font-size: 13px; + margin-top: 20px; margin-bottom: 20px; } @@ -124,10 +132,10 @@ body { } .venn-header { - font-size: 14px; - margin-right: 50px; - margin-bottom: 20px; - margin-left: 50px; + font-size: 14px; + margin-right: 50px; + margin-bottom: 20px; + margin-left: 50px; } .venn { @@ -181,4 +189,62 @@ body { .search-param-label { font-size: 13px; color: var(--color-medium-dark-grey); -} \ No newline at end of file +} + +.search-param-value { + font-size: 12px; + margin-left: 5px; +} + +.genome-detail-container { + margin-top: 15px; +} + +.genome-detail { + font-size: 13px; + text-decoration: none; + color: var(--color-blue); +} + +.genome-detail-margin-left { + margin-left: 5px; +} + +.italic { + font-style: italic; +} + +.tooltip-content { + padding: 12px; + position: relative; + background-color: var(--color-black); + width: 140px; + min-height: 20px; + box-shadow: 2px 2px 2px rgba(0,0,0,0.1); + text-align: left; +} + +#tooltip .tooltip-content::after { + content: ""; + position: absolute; + top: 35%; + left: 0%; + margin-left: -14px; + border-width: 7px; + border-style: solid; + border-color: transparent var(--color-black) transparent transparent; +} + +.tooltip-content-title { + font-size: 12px; + color: var(--color-medium-light-grey); + font-weight: var(--font-weight-light); +} + +#genome-browser-link, +#entity-viewer-link { + background-color: var(--color-blue); + padding: 2px 4px; + margin-left: 20px; + display: inline-block; +} diff --git a/app/static/js/index.js b/app/static/js/index.js new file mode 100644 index 0000000..5e88a67 --- /dev/null +++ b/app/static/js/index.js @@ -0,0 +1,65 @@ +function closeTooltip() { + const tooltip = document.getElementById('tooltip'); + if (tooltip) { + tooltip.style.display = 'none'; + } +} + +function showTooltip(el) { + const tooltip = document.getElementById('tooltip'); + if (!tooltip) return; + + const genomeBrowserLink = document.getElementById('genome-browser-link'); + const entityViewerLink = document.getElementById('entity-viewer-link'); + + if (genomeBrowserLink) { + genomeBrowserLink.href = el.dataset.genomeBrowserUrl; + } + if (entityViewerLink) { + entityViewerLink.href = el.dataset.entityViewerUrl; + } + + const target = el.querySelector('.genome-detail-tooltip'); + if (!target) return; + + tooltip.style.display = 'block'; + tooltip.setAttribute('aria-hidden', 'false'); + const rect = target.getBoundingClientRect(); + tooltip.style.top = (rect.top - 15 + window.scrollY) + 'px'; + tooltip.style.left = (rect.right + 10 + window.scrollX) + 'px'; + genomeBrowserLink.focus(); +} + +document.querySelectorAll('.genome-detail-container').forEach(function(el) { + el.addEventListener('keydown', function(e) { + const link = e.target.closest('.genome-detail'); + if (link && el.contains(link) && e.key === 'Enter') { + e.preventDefault(); + showTooltip(el); + } + }) + + el.addEventListener('click', function(e) { + const link = e.target.closest('.genome-detail'); + if (link && el.contains(link)) { + e.preventDefault(); + showTooltip(el); + } + }); +}); + +document.addEventListener('click', function(e) { + if (!e.target.closest('.genome-detail-container') && !e.target.closest('#tooltip')) { + closeTooltip(); + } +}); + +window.addEventListener('resize', function() { + closeTooltip(); +}); + +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + closeTooltip(); + } +}); diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/test_rapid.py b/tests/test_rapid.py similarity index 87% rename from app/tests/test_rapid.py rename to tests/test_rapid.py index cf083ca..478f31a 100644 --- a/app/tests/test_rapid.py +++ b/tests/test_rapid.py @@ -1,9 +1,10 @@ import unittest from unittest.mock import patch from fastapi.testclient import TestClient -from api.models.resolver import RapidResolverResponse -from core.config import ENSEMBL_URL -from main import app + +from app.api.models.resolver import RapidResolverResponse +from app.core.config import ENSEMBL_URL +from app.main import app class TestRapid(unittest.TestCase): @@ -11,6 +12,7 @@ def setUp(self): self.client = TestClient(app) self.api_prefix = "" self.mock_rapid_api_url = "/rapid" + self.stable_id = "ENSAROG00010015245" self.species_url_name = "Human_GCA_123.1" self.species_url_name_refseq = "DogRefSeq_GCA_123.1rs" @@ -90,7 +92,7 @@ def test_rapid_blast_success(self): # Test species home page - @patch("api.resources.rapid_view.get_genome_id_from_assembly_accession_id") + @patch("app.api.resources.rapid_view.get_genome_id_from_assembly_accession_id") def test_rapid_species_home_success( self, mock_get_genome_id_from_assembly_accession_id ): @@ -113,7 +115,7 @@ def test_rapid_species_home_success( # Test Region in detail page - @patch("api.resources.rapid_view.get_genome_id_from_assembly_accession_id") + @patch("app.api.resources.rapid_view.get_genome_id_from_assembly_accession_id") def test_rapid_species_location_success( self, mock_get_genome_id_from_assembly_accession_id ): @@ -136,7 +138,7 @@ def test_rapid_species_location_success( ) # Test Gene pages - @patch("api.resources.rapid_view.get_genome_id_from_assembly_accession_id") + @patch("app.api.resources.rapid_view.get_genome_id_from_assembly_accession_id") def test_rapid_species_gene_compara_homolog( self, mock_get_genome_id_from_assembly_accession_id ): @@ -161,7 +163,7 @@ def test_rapid_species_gene_compara_homolog( ) # Test 404 - @patch("api.resources.rapid_view.get_genome_id_from_assembly_accession_id") + @patch("app.api.resources.rapid_view.get_genome_id_from_assembly_accession_id") def test_rapid_species_404_not_found( self, mock_get_genome_id_from_assembly_accession_id ): @@ -187,7 +189,7 @@ def test_rapid_species_post_method_not_allowed(self): self.assertEqual(response.status_code, 405) # Test 500 - @patch("api.resources.rapid_view.get_genome_id_from_assembly_accession_id") + @patch("app.api.resources.rapid_view.get_genome_id_from_assembly_accession_id") def test_rapid_species_500_internal_server_error( self, mock_get_genome_id_from_assembly_accession_id ): @@ -204,3 +206,15 @@ def test_rapid_species_500_internal_server_error( headers={"accept": "application/json"}, ) self.assertEqual(response.status_code, 500) + + + @patch("app.api.resources.rapid_view.get_search_results") + def test_rapid_id_resolve_404(self, mock_get_search_results): + + mock_get_search_results.return_value = {} + + response = self.client.get( + f"{self.mock_rapid_api_url}/id/{self.stable_id}", follow_redirects=False, + headers = {"accept": "application/json"} + ) + self.assertEqual(response.status_code, 404) diff --git a/app/tests/test_resolver.py b/tests/test_resolver.py similarity index 87% rename from app/tests/test_resolver.py rename to tests/test_resolver.py index b34be69..432c727 100644 --- a/app/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -1,8 +1,9 @@ import unittest from unittest.mock import patch from fastapi.testclient import TestClient -from core.config import ENSEMBL_URL, DEFAULT_APP -from main import app + +from app.core.config import ENSEMBL_URL, DEFAULT_APP +from app.main import app class TestResolverAPI(unittest.TestCase): @@ -57,8 +58,8 @@ def setUp(self): "genome2": f"{ENSEMBL_URL}/{DEFAULT_APP}/genome2/gene:{self.stable_id}", } - @patch("api.resources.resolver_view.get_search_results") - @patch("api.resources.resolver_view.get_metadata") + @patch("app.api.resources.resolver_view.get_search_results") + @patch("app.api.resources.resolver_view.get_metadata") def test_resolve_success_with_json_response( self, mock_get_metadata, mock_get_search_results ): @@ -77,11 +78,11 @@ def test_resolve_success_with_json_response( json_response = response.json() self.assertEqual(len(json_response), 2) self.assertEqual( - json_response[0]["resolved_url"], self.mock_resolved_url["genome1"] + json_response[0]["entity_viewer_url"], self.mock_resolved_url["genome1"] ) - @patch("api.resources.resolver_view.get_search_results") - @patch("api.resources.resolver_view.get_metadata") + @patch("app.api.resources.resolver_view.get_search_results") + @patch("app.api.resources.resolver_view.get_metadata") def test_resolve_success_with_redirect( self, mock_get_metadata, mock_get_search_results ): @@ -99,8 +100,8 @@ def test_resolve_success_with_redirect( response.headers["location"], self.mock_resolved_url["genome1"] ) - @patch("api.resources.resolver_view.get_search_results") - @patch("api.resources.resolver_view.get_metadata") + @patch("app.api.resources.resolver_view.get_search_results") + @patch("app.api.resources.resolver_view.get_metadata") def test_resolve_success_with_html_response( self, mock_get_metadata, mock_get_search_results ): @@ -118,12 +119,13 @@ def test_resolve_success_with_html_response( "Failed resolving multiple results with html response", ) - @patch("api.resources.resolver_view.get_search_results") + @patch("app.api.resources.resolver_view.get_search_results") def test_resolve_404(self, mock_get_search_results): mock_get_search_results.return_value = {} response = self.client.get( - f"{self.mock_search_api_url}/{self.stable_id}", follow_redirects=False + f"{self.mock_search_api_url}/{self.stable_id}", follow_redirects=False, + headers = {"accept": "application/json"} ) self.assertEqual(response.status_code, 404)