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
-
-
- Assembly Name |
- Accession ID |
- Scientific name |
- Common name |
- Type |
- Resolved URL |
-
- {% for item in results %}
-
- {{ 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
- |
-
- {% endfor %}
-
-
-
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 %}
+
+
+
+
+ Gene
+ {{ response.stable_id }}
+
+ {% if response.code == 404 and response.message %}
+
+ {{ response.message }}
+
+ {{ url_helper.render_url("Go to", "https://beta.ensembl.org/species-selector") }}
+ {% else %}
+ {% for item in response.content %}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+ You will be redirected to the new Ensembl website, where you will find the latest genomic information
+
+
+
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)