diff --git a/app/api/models/resolver.py b/app/api/models/resolver.py index 90fa309..f72c288 100644 --- a/app/api/models/resolver.py +++ b/app/api/models/resolver.py @@ -4,9 +4,9 @@ class SearchPayload(BaseModel): - stable_id: str = Field(default=None, title="Stable ID of a gene") - type: Literal["gene"] | None = Field( - default=None, title="Type of stable id, e.g. gene" + stable_id: str = Field(default=None, title="Stable ID") + type: Literal["gene", "transcript", "protein"] | None = Field( + default=None, title="Optional stable-ID type filter" ) per_page: int = 1 app: Literal["genome-browser", "feature-explorer"] = Field( @@ -15,8 +15,9 @@ class SearchPayload(BaseModel): class SearchMatch(BaseModel): - genome: str + genome_id: str unversioned_stable_id: str + type: Literal["gene", "transcript", "protein"] | None = None class SearchResult(BaseModel): @@ -89,6 +90,7 @@ class UrlResolverResponse(BaseModel): class StableIdResolverContent(MetadataResult): + stable_id_type: Literal["gene", "transcript", "protein"] = "gene" feature_explorer_url: str | None = None genome_browser_url: str | None = None release_type: str | None = None diff --git a/app/api/resources/resolver_view.py b/app/api/resources/resolver_view.py index 291dc1f..78b324e 100644 --- a/app/api/resources/resolver_view.py +++ b/app/api/resources/resolver_view.py @@ -26,7 +26,7 @@ async def resolve( request: Request, stable_id: str, - type: Optional[str] = "gene", + type: Optional[Literal["gene", "transcript", "protein"]] = None, gca: Optional[str] = "", app: Optional[Literal["genome-browser", "feature-explorer"]] = DEFAULT_APP, ): diff --git a/app/api/resources/templates/shared/id_content.html b/app/api/resources/templates/shared/id_content.html index 5d4d25d..3bb82fb 100644 --- a/app/api/resources/templates/shared/id_content.html +++ b/app/api/resources/templates/shared/id_content.html @@ -3,7 +3,7 @@
- Gene + Stable ID {{ response.stable_id }}
{% if (response.code == 404 or response.code == 500) and response.message %} diff --git a/app/api/utils/commons.py b/app/api/utils/commons.py index 951bc59..cd0f230 100644 --- a/app/api/utils/commons.py +++ b/app/api/utils/commons.py @@ -13,10 +13,14 @@ def build_stable_id_resolver_content(metadata_results) -> list[StableIdResolverC content = StableIdResolverContent( feature_explorer_url=build_feature_explorer_url( - genome_id, metadata["unversioned_stable_id"] + genome_id, + metadata["unversioned_stable_id"], + metadata.get("stable_id_type", "gene"), ), genome_browser_url=build_genome_browser_url( - genome_id, metadata["unversioned_stable_id"] + genome_id, + metadata["unversioned_stable_id"], + metadata.get("stable_id_type", "gene"), ), release_type=metadata.get("release", {}).get("type", ""), release_name=metadata.get("release", {}).get("name", ""), @@ -27,12 +31,19 @@ def build_stable_id_resolver_content(metadata_results) -> list[StableIdResolverC return results -def build_feature_explorer_url(genome_id: str, stable_id: str) -> str: - return f"{ENSEMBL_URL}/feature-explorer/{genome_id}/gene:{stable_id}" +def build_feature_explorer_url( + genome_id: str, stable_id: str, stable_id_type: str = "gene" +) -> str: + return f"{ENSEMBL_URL}/feature-explorer/{genome_id}/{stable_id_type}:{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 build_genome_browser_url( + genome_id: str, stable_id: str, stable_id_type: str = "gene" +) -> str: + return ( + f"{ENSEMBL_URL}/genome-browser/{genome_id}" + f"?focus={stable_id_type}:{stable_id}" + ) def is_json_request(request) -> bool: diff --git a/app/api/utils/metadata.py b/app/api/utils/metadata.py index 0053993..3081bf9 100644 --- a/app/api/utils/metadata.py +++ b/app/api/utils/metadata.py @@ -25,6 +25,11 @@ def get_metadata(matches: List[SearchMatch] = []): metadata_results[genome_id]["unversioned_stable_id"] = match.get( "unversioned_stable_id" ) + # Fast-match supplies this field. Keep the historical gene + # default for search-hub responses that do not yet include it. + metadata_results[genome_id]["stable_id_type"] = ( + match.get("type") or match.get("doc_type") or "gene" + ) except Exception as e: raise Exception( f"Failed to fetch metadata for genome '{genome_id}': {e}" diff --git a/app/api/utils/search.py b/app/api/utils/search.py index d2b6510..d60b453 100644 --- a/app/api/utils/search.py +++ b/app/api/utils/search.py @@ -71,7 +71,7 @@ def get_fast_match_results(params: SearchPayload): # version suffixes (for example, ``ENSG00000127720.3``) are removed. unversioned_stable_id = re.sub(r"\.\d+$", "", params.stable_id) matches = [] - seen_genome_ids = set() + seen_matches = set() for raw_match in raw_matches.split("+"): try: @@ -89,14 +89,19 @@ def get_fast_match_results(params: SearchPayload): if params.type and doc_type != params.type: continue - if genome_id not in seen_genome_ids: + # A stable ID normally identifies one feature type, but retain the type + # in both the result and deduplication key when no filter was supplied. + # The resolver needs it to construct a gene or transcript destination. + match_key = (genome_id, doc_type) + if match_key not in seen_matches: matches.append( { "genome_id": genome_id, "unversioned_stable_id": unversioned_stable_id, + "type": doc_type, } ) - seen_genome_ids.add(genome_id) + seen_matches.add(match_key) logger.info( f"Fast-match stable ID lookup result: {len(matches)} matches " diff --git a/app/static/APISpecification.yaml b/app/static/APISpecification.yaml index 8f83a11..c68e067 100644 --- a/app/static/APISpecification.yaml +++ b/app/static/APISpecification.yaml @@ -42,7 +42,7 @@ paths: parameters: - name: stable_id in: path - description: Gene stable id used to represent a gene in a genome. + description: Gene, transcript, or protein stable ID. required: true explode: true schema: @@ -50,11 +50,11 @@ paths: example: ENSG00000221914.11 - name: type in: query - description: Defines a type - gene/transcript/variant etc + description: Optional stable-ID type filter. If omitted, all supported types are searched. required: false schema: type: string - enum: [ gene, transcript, variant ] + enum: [ gene, transcript, protein ] example: gene - name: app diff --git a/stable_ids_sample.csv.redb b/stable_ids_sample.csv.redb new file mode 100644 index 0000000..a01f642 Binary files /dev/null and b/stable_ids_sample.csv.redb differ diff --git a/tests/test_fast_match_search.py b/tests/test_fast_match_search.py index 6047a39..06bab16 100644 --- a/tests/test_fast_match_search.py +++ b/tests/test_fast_match_search.py @@ -51,10 +51,12 @@ def test_returns_matches_with_unversioned_stable_id(self, mock_fm_py): { "genome_id": "genome-1", "unversioned_stable_id": "ENSG00000127720", + "type": "gene", }, { "genome_id": "genome-2", "unversioned_stable_id": "ENSG00000127720", + "type": "gene", }, ] }, @@ -80,11 +82,39 @@ def test_filters_other_document_types_and_duplicate_genomes(self, mock_fm_py): { "genome_id": "genome-2", "unversioned_stable_id": "ENSG00000127720", + "type": "gene", } ] }, ) + @patch("app.api.utils.search.fm_py") + @patch("app.api.utils.search.FAST_MATCH_ENABLED", True) + @patch("app.api.utils.search.FAST_MATCH_DB_PATH", "/data/stable-ids.redb") + def test_returns_supported_types_when_no_type_filter_is_supplied(self, mock_fm_py): + mock_fm_py.find_key.return_value = "genome-1|gene+genome-1|transcript" + params = SearchPayload(stable_id="ENSX000001", type=None, per_page=10) + + result = get_search_results(params) + + self.assertEqual( + result, + { + "matches": [ + { + "genome_id": "genome-1", + "unversioned_stable_id": "ENSX000001", + "type": "gene", + }, + { + "genome_id": "genome-1", + "unversioned_stable_id": "ENSX000001", + "type": "transcript", + }, + ] + }, + ) + @patch("app.api.utils.search.fm_py") @patch("app.api.utils.search.FAST_MATCH_ENABLED", True) @patch("app.api.utils.search.FAST_MATCH_DB_PATH", "/data/stable-ids.redb") diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 4e7db36..88321da 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -106,6 +106,39 @@ def test_resolve_success_with_redirect( response.headers["location"], self.mock_resolved_url["genome1"] ) + @patch("app.api.resources.resolver_view.get_search_results") + @patch("app.api.resources.resolver_view.get_metadata") + def test_resolves_transcript_using_the_matched_type( + self, mock_get_metadata, mock_get_search_results + ): + transcript_id = "ENST00000357654" + mock_get_search_results.return_value = { + "matches": [ + { + "genome_id": "genome1", + "unversioned_stable_id": transcript_id, + "type": "transcript", + } + ] + } + mock_get_metadata.return_value = { + "genome1": { + **self.mock_single_metadata_results_success["genome1"], + "unversioned_stable_id": transcript_id, + "stable_id_type": "transcript", + } + } + + response = self.client.get( + f"/id/{transcript_id}", params={"app": "genome-browser"}, follow_redirects=False + ) + + self.assertEqual(response.status_code, 307) + self.assertEqual( + response.headers["location"], + f"{ENSEMBL_URL}/genome-browser/genome1?focus=transcript:{transcript_id}", + ) + @patch("app.api.resources.resolver_view.get_search_results") @patch("app.api.resources.resolver_view.get_metadata") def test_resolve_success_with_html_response(