Skip to content

Commit 9f9af9a

Browse files
Resolver and Rapid resolver stable Id redirect Intermediate page (#15)
1 parent 1fc5ac5 commit 9f9af9a

35 files changed

+646
-214
lines changed

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,22 @@ The resolver service generates new Ensembl website urls for different features b
1717

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

20+
### Running application in Local
21+
22+
From the project root directory run the following commands
23+
24+
`$ mv sample-env .env`
25+
26+
`$ python3 -m venv venv`
27+
28+
`$ source venv/bin/activate`
29+
30+
`$ pip install -r requirements.txt`
31+
32+
`$ python3 -m uvicorn app.main:app --port 8001 --reload`
33+
2034
### Run unit tests:
2135
```
22-
cd app
36+
python -m unittest tests.test_resolver
2337
python -m unittest tests.test_rapid
2438
```
File renamed without changes.

app/api/__init__.py

Whitespace-only changes.

app/api/models/resolver.py

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
from enum import Enum
2-
from typing import Optional, Literal, List, Dict, Annotated
3-
from pydantic import BaseModel, Field
2+
from typing import Literal
3+
from pydantic import BaseModel, Field, ConfigDict
44

55

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

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

2121

2222
class SearchResult(BaseModel):
23-
matches: List[SearchMatch] = []
23+
matches: list[SearchMatch]
2424

2525

2626
class Assembly(BaseModel):
@@ -29,10 +29,10 @@ class Assembly(BaseModel):
2929

3030

3131
class MetadataResult(BaseModel):
32-
assembly: Assembly
33-
scientific_name: str
34-
common_name: str
35-
type: Optional[Dict[str, str]] = None
32+
assembly: Assembly | None = None
33+
scientific_name: str | None = None
34+
common_name: str | None = None
35+
type: dict[str, str] | None = None
3636
is_reference: bool = False
3737

3838

@@ -47,16 +47,41 @@ class RapidResolverHtmlResponseType(str, Enum):
4747
HELP = "HELP"
4848
INFO = "INFO"
4949

50-
# Exclude all fields except resolved_url in JSON response.
50+
5151
class RapidResolverResponse(BaseModel):
5252
resolved_url: str
53-
response_type: Annotated[Optional[RapidResolverHtmlResponseType], Field(exclude=True)] = None
54-
code: Annotated[Optional[int], Field(exclude=True)] = None
55-
species_name: Annotated[Optional[str], Field(exclude=True)] = None
56-
gene_id: Annotated[Optional[str], Field(exclude=True)] = None
57-
location: Annotated[Optional[str], Field(exclude=True)] = None
58-
message: Annotated[Optional[str], Field(exclude=True)] = None
59-
rapid_archive_url: Annotated[Optional[str], Field(exclude=True)] = None
60-
61-
class Config:
62-
use_enum_values = True
53+
response_type: RapidResolverHtmlResponseType | None = None
54+
code: int | None = None
55+
species_name: str | None = None
56+
gene_id: str | None = None
57+
location: str | None = None
58+
message: str | None = None
59+
rapid_archive_url: str | None = None
60+
61+
model_config = ConfigDict(use_enum_values=True)
62+
63+
_excluded_fields = {
64+
"response_type", "code", "species_name", "gene_id",
65+
"location", "message", "rapid_archive_url"
66+
}
67+
68+
def model_dump(self, *args, **kwargs):
69+
kwargs.setdefault("exclude", self._excluded_fields)
70+
return super().model_dump(*args, **kwargs)
71+
72+
def model_dump_json(self, *args, **kwargs):
73+
kwargs.setdefault("exclude", self._excluded_fields)
74+
return super().model_dump_json(*args, **kwargs)
75+
76+
77+
class StableIdResolverContent(MetadataResult):
78+
entity_viewer_url: str | None = None
79+
genome_browser_url: str | None = None
80+
81+
82+
class StableIdResolverResponse(BaseModel):
83+
stable_id: str
84+
code: int | None = None
85+
message: str | None = None
86+
rapid_archive_url: str | None = None
87+
content: list[StableIdResolverContent] | None = None

app/api/resources/rapid_view.py

Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,71 @@
1-
import os
21
from urllib.parse import parse_qs
32

4-
from dotenv import load_dotenv
53
from fastapi import APIRouter, Request, Query, HTTPException
64

7-
from jinja2 import Environment, FileSystemLoader
85
from starlette.responses import HTMLResponse
96

107
import logging
11-
from api.models.resolver import RapidResolverResponse, RapidResolverHtmlResponseType
12-
from core.logging import InterceptHandler
13-
from core.config import ENSEMBL_URL
14-
from api.utils.metadata import get_genome_id_from_assembly_accession_id
15-
from api.utils.rapid import construct_url, format_assembly_accession, construct_rapid_archive_url
8+
9+
from app.api.error_response import response_error_handler
10+
from app.api.models.resolver import RapidResolverResponse, RapidResolverHtmlResponseType, SearchPayload, \
11+
StableIdResolverResponse
12+
from app.api.utils.commons import build_stable_id_resolver_content, is_json_request
13+
from app.api.utils.metadata import get_genome_id_from_assembly_accession_id, get_metadata
14+
from app.api.utils.rapid import format_assembly_accession, construct_rapid_archive_url, construct_url, \
15+
generate_rapid_id_page, generate_rapid_page
16+
from app.api.utils.search import get_search_results
17+
from app.core.config import ENSEMBL_URL, RAPID_ARCHIVE_URL
18+
from app.core.logging import InterceptHandler
1619

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

1922
router = APIRouter()
2023

2124

25+
@router.get("/id/{stable_id}", name="Resolve rapid stable ID")
26+
async def resolve_rapid_stable_id(request: Request, stable_id: str):
27+
# Handle only gene stable id for now
28+
params = SearchPayload(stable_id=stable_id, type="gene", per_page=10)
29+
search_results = get_search_results(params)
30+
rapid_archive_url = f"{RAPID_ARCHIVE_URL}/id/{stable_id}"
31+
32+
if not search_results or not search_results.get("matches"):
33+
if is_json_request(request):
34+
return response_error_handler({"status": 404})
35+
res = StableIdResolverResponse(
36+
stable_id=stable_id,
37+
code=404,
38+
message="No results",
39+
content=None,
40+
rapid_archive_url=rapid_archive_url
41+
)
42+
return HTMLResponse(generate_rapid_id_page(res))
43+
44+
matches = search_results.get("matches")
45+
metadata_results = get_metadata(matches)
46+
47+
stable_id_resolver_response = StableIdResolverResponse(
48+
stable_id=stable_id,
49+
code=308,
50+
rapid_archive_url=rapid_archive_url
51+
)
52+
results = build_stable_id_resolver_content(metadata_results)
53+
stable_id_resolver_response.content = results
54+
55+
if is_json_request(request):
56+
return results
57+
58+
return HTMLResponse(generate_rapid_id_page(stable_id_resolver_response))
59+
60+
2261
@router.get("/info/{subpath:path}", name="Resolve rapid help page")
2362
async def resolve_rapid_help(request: Request, subpath: str = ""):
2463
response = RapidResolverResponse(
2564
response_type=RapidResolverHtmlResponseType.HELP,
2665
code=308,
2766
resolved_url=f"{ENSEMBL_URL}/help",
2867
)
29-
return resolved_response(response, request)
68+
return rapid_resolved_response(response, request)
3069

3170

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

4180

4281
# Resolve rapid urls
@@ -53,7 +92,7 @@ async def resolve_species(
5392
resolved_url=f"{ENSEMBL_URL}/blast",
5493
species_name=species_url_name,
5594
)
56-
return resolved_response(response, request)
95+
return rapid_resolved_response(response, request)
5796

5897
assembly_accession_id = format_assembly_accession(species_url_name)
5998

@@ -65,7 +104,7 @@ async def resolve_species(
65104
message="Invalid input accession ID",
66105
species_name=species_url_name,
67106
)
68-
return resolved_response(input_error_response, request)
107+
return rapid_resolved_response(input_error_response, request)
69108

70109
try:
71110
genome_object = get_genome_id_from_assembly_accession_id(assembly_accession_id)
@@ -88,7 +127,7 @@ async def resolve_species(
88127
location=query_params.get("r", [None])[0],
89128
rapid_archive_url=rapid_archive_url,
90129
)
91-
return resolved_response(response, request)
130+
return rapid_resolved_response(response, request)
92131
else:
93132
raise HTTPException(status_code=404, detail="Genome not found")
94133
except HTTPException as e:
@@ -100,7 +139,7 @@ async def resolve_species(
100139
message=e.detail,
101140
species_name=species_url_name,
102141
)
103-
return resolved_response(response, request)
142+
return rapid_resolved_response(response, request)
104143
except Exception as e:
105144
logging.debug(f"Unexpected error occurred: {e}")
106145
response = RapidResolverResponse(
@@ -110,7 +149,7 @@ async def resolve_species(
110149
resolved_url=f"{ENSEMBL_URL}/species-selector",
111150
message=str(e),
112151
)
113-
return resolved_response(response, request)
152+
return rapid_resolved_response(response, request)
114153

115154

116155
@router.get("/", name="Rapid Home")
@@ -120,30 +159,15 @@ async def resolve_home(request: Request):
120159
code=308,
121160
resolved_url=ENSEMBL_URL,
122161
)
123-
return resolved_response(response, request)
162+
return rapid_resolved_response(response, request)
124163

125164

126-
def resolved_response(response: RapidResolverResponse, request: Request):
127-
# Return JSON response if requested
128-
if "application/json" in request.headers.get("accept"):
129-
# Handle error responses for JSON requests
165+
def rapid_resolved_response(response: RapidResolverResponse, request: Request):
166+
if is_json_request(request):
130167
if response.response_type == RapidResolverHtmlResponseType.ERROR:
131168
raise HTTPException(
132169
status_code=response.code,
133170
detail=response.message or "An error occurred",
134171
)
135-
# Doesn't raise redirect for JSON requests, just return the URL. Because swagger UI doesn't handle redirects well.
136-
# So code is always 200 for successful JSON response.
137-
return response
138-
139-
# Default to HTML response
140-
return HTMLResponse(generate_html_content(response))
141-
142-
143-
def generate_html_content(response):
144-
load_dotenv()
145-
CURR_DIR = os.path.dirname(os.path.abspath(__file__))
146-
env = Environment(loader=FileSystemLoader(os.path.join(CURR_DIR, "templates/rapid")))
147-
rapid_redirect_page_template = env.get_template("main.html")
148-
rapid_redirect_page_html = rapid_redirect_page_template.render(response=response)
149-
return rapid_redirect_page_html
172+
return response.model_dump()
173+
return HTMLResponse(generate_rapid_page(response))

app/api/resources/resolver_view.py

Lines changed: 34 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import os
21
from fastapi import APIRouter, Request
3-
from typing import Optional, Literal, List
2+
from typing import Optional, Literal
43
from fastapi.responses import RedirectResponse, HTMLResponse
54
import logging
6-
from dotenv import load_dotenv
7-
from jinja2 import Environment, FileSystemLoader
8-
from core.logging import InterceptHandler
9-
from api.models.resolver import SearchPayload, ResolvedPayload
10-
from api.error_response import response_error_handler
11-
from core.config import DEFAULT_APP, ENSEMBL_URL
12-
from api.utils.metadata import get_metadata
13-
from api.utils.search import get_search_results
5+
6+
from app.api.error_response import response_error_handler
7+
from app.api.models.resolver import SearchPayload, StableIdResolverResponse
8+
from app.api.utils.commons import build_stable_id_resolver_content, is_json_request
9+
from app.api.utils.metadata import get_metadata
10+
from app.api.utils.resolver import generate_resolver_id_page
11+
from app.api.utils.search import get_search_results
12+
from app.core.config import DEFAULT_APP
13+
from app.core.logging import InterceptHandler
1414

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

@@ -27,52 +27,40 @@ async def resolve(
2727
):
2828

2929
params = SearchPayload(stable_id=stable_id, type=type, per_page=10)
30-
31-
# Get genome_ids from search api
3230
search_results = get_search_results(params)
3331

34-
if not search_results:
35-
return response_error_handler({"status": 404})
32+
if not search_results or not search_results.get("matches"):
33+
if is_json_request(request):
34+
return response_error_handler({"status": 404})
35+
36+
res = StableIdResolverResponse(
37+
stable_id=stable_id,
38+
code=404,
39+
message="No results",
40+
content=None
41+
)
42+
return HTMLResponse(generate_resolver_id_page(res))
3643

3744
matches = search_results.get("matches")
38-
if not matches:
39-
return response_error_handler({"status": 404})
4045

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

44-
results: List[ResolvedPayload] = []
45-
46-
for genome_id in metadata_results:
47-
48-
metadata = metadata_results[genome_id]
49-
50-
if not metadata:
51-
continue
52-
53-
if app == "entity-viewer":
54-
url = f"{ENSEMBL_URL}/{app}/{genome_id}/{type}:{metadata['unversioned_stable_id']}"
55-
else:
56-
url = f"{ENSEMBL_URL}/{app}/{genome_id}?focus={type}:{metadata['unversioned_stable_id']}"
49+
stable_id_resolver_response = StableIdResolverResponse(
50+
stable_id=stable_id,
51+
code=308,
52+
)
53+
results = build_stable_id_resolver_content(metadata_results)
54+
stable_id_resolver_response.content = results
5755

58-
metadata["resolved_url"] = url
59-
resolved_payload = ResolvedPayload(**metadata)
60-
results.append(resolved_payload.model_dump())
61-
62-
if "application/json" in request.headers.get("accept"):
56+
if is_json_request(request):
6357
return results
6458

6559
if len(results) == 1:
66-
return RedirectResponse(results[0]["resolved_url"])
60+
if app == "entity-viewer":
61+
resolved_url = results[0].entity_viewer_url
62+
else:
63+
resolved_url = results[0].genome_browser_url
64+
return RedirectResponse(resolved_url)
6765
else:
68-
return HTMLResponse(generate_html_content(results))
69-
70-
71-
def generate_html_content(results):
72-
# Create a simple HTML page with a list of URLs
73-
load_dotenv()
74-
CURR_DIR = os.path.dirname(os.path.abspath(__file__))
75-
env = Environment(loader=FileSystemLoader(os.path.join(CURR_DIR, "templates")))
76-
search_results_template = env.get_template("search_results.html")
77-
search_results_html = search_results_template.render(results=results)
78-
return search_results_html
66+
return HTMLResponse(generate_resolver_id_page(stable_id_resolver_response))

0 commit comments

Comments
 (0)