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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 58 additions & 12 deletions api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,34 @@
import time
import os
import re
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Union, Annotated, Optional

from fastapi import Body, FastAPI, Query
from fastapi import Body, FastAPI, Query, HTTPException
from fastapi.responses import RedirectResponse
import httpx
from pydantic import BaseModel, Field
from starlette.middleware.cors import CORSMiddleware

from api.apidocs import get_app_info, construct_open_api_schema

SOLR_HOST = os.getenv("SOLR_HOST", "localhost")
SOLR_PORT = os.getenv("SOLR_PORT", "8983")
@dataclass(frozen=True)
class Config:
"""Runtime configuration, populated from environment variables at import."""
# Solr connection (private — not exposed via /status).
solr_host: str = os.getenv("SOLR_HOST", "localhost")
solr_port: str = os.getenv("SOLR_PORT", "8983")
# Queries shorter than this (after strip) are rejected: single-char queries
# are slow in Solr and never useful. Translator expects results at length 2.
minimum_query_length: int = int(os.getenv("NAMERES_MINIMUM_QUERY_LENGTH", "2"))

def public(self) -> dict:
"""Config values safe to surface via /status. Infra config stays private."""
return {"minimum_query_length": self.minimum_query_length}


config = Config()

app = FastAPI(**get_app_info())
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -57,7 +72,7 @@ async def status_get() -> Dict:

async def status() -> Dict:
""" Return a dictionary containing status and count information for the underlying Solr instance. """
query_url = f"http://{SOLR_HOST}:{SOLR_PORT}/solr/admin/cores"
query_url = f"http://{config.solr_host}:{config.solr_port}/solr/admin/cores"
async with httpx.AsyncClient(timeout=None) as client:
response = await client.get(query_url, params={
'action': 'STATUS'
Expand Down Expand Up @@ -102,6 +117,7 @@ async def status() -> Dict:
'download_url': biolink_model_download_url,
},
'nameres_version': nameres_version,
'config': config.public(),
'startTime': core['startTime'],
'numDocs': index.get('numDocs', ''),
'maxDoc': index.get('maxDoc', ''),
Expand All @@ -123,6 +139,7 @@ async def status() -> Dict:
'download_url': biolink_model_download_url,
},
'nameres_version': nameres_version,
'config': config.public(),
}


Expand Down Expand Up @@ -219,7 +236,7 @@ async def synonyms_post(
async def name_lookup(curies) -> Dict[str, Dict]:
"""Returns a list of synonyms for a particular CURIE."""
time_start = time.time_ns()
query = f"http://{SOLR_HOST}:{SOLR_PORT}/solr/name_lookup/select"
query = f"http://{config.solr_host}:{config.solr_port}/solr/name_lookup/select"
curie_filter = " OR ".join(
f"curie:\"{curie}\""
for curie in curies
Expand Down Expand Up @@ -263,11 +280,22 @@ class LookupResult(BaseModel):
"<p>You can find out more about this endpoint in the <a href=\"https://github.com/NCATSTranslator/NameResolution/blob/master/documentation/API.md#lookup\">API documentation</a>.</p>"
"<p>Note that CURIEs are conflated with both GeneProtein and DrugChemical conflation, so that e.g. when searching for a protein, the identifier of the gene that encodes the protein will be returned itself. See <a href=\"https://github.com/NCATSTranslator/NameResolution/blob/master/documentation/API.md#Conflation\">Conflation documentation</a> for more information.</p>",
response_model=List[LookupResult],
responses={
422: {
"description": "The request was rejected without being searched. This includes queries shorter "
"than the configured minimum length (see `minimum_query_length` in `/status`, "
"default 2 characters after leading/trailing whitespace is stripped), since "
"single-character queries are slow and never return useful results, as well as any "
"other request parameter that failed validation."
}
},
tags=["lookup"]
)
async def lookup_curies_get(
string: Annotated[str, Query(
description="The string to search for."
description="The string to search for. Must be at least the configured minimum length "
"(see `minimum_query_length` in `/status`, default 2) after leading/trailing "
"whitespace is stripped; shorter queries are rejected with HTTP 422."
)],
autocomplete: Annotated[bool, Query(
description="Is the input string incomplete (autocomplete=true) or a complete phrase (autocomplete=false)?"
Expand Down Expand Up @@ -318,7 +346,7 @@ async def lookup_curies_get(
"""
Returns cliques with a name or synonym that contains a specified string.
"""
return await lookup(string, autocomplete, highlighting, offset, limit, biolink_type, only_prefixes, exclude_prefixes, only_taxa, debug)
return await lookup(string, autocomplete, highlighting, offset, limit, biolink_type, only_prefixes, exclude_prefixes, only_taxa, debug, raise_on_too_short=True)


@app.post("/lookup",
Expand All @@ -327,11 +355,22 @@ async def lookup_curies_get(
"<p>You can find out more about this endpoint in the <a href=\"https://github.com/NCATSTranslator/NameResolution/blob/master/documentation/API.md#lookup\">API documentation</a>.</p>"
"<p>Note that CURIEs are conflated with both GeneProtein and DrugChemical conflation, so that e.g. when searching for a protein, the identifier of the gene that encodes the protein will be returned itself. See <a href=\"https://github.com/NCATSTranslator/NameResolution/blob/master/documentation/API.md#Conflation\">Conflation documentation</a> for more information.</p>",
response_model=List[LookupResult],
responses={
422: {
"description": "The request was rejected without being searched. This includes queries shorter "
"than the configured minimum length (see `minimum_query_length` in `/status`, "
"default 2 characters after leading/trailing whitespace is stripped), since "
"single-character queries are slow and never return useful results, as well as any "
"other request parameter that failed validation."
}
},
tags=["lookup"]
)
async def lookup_curies_post(
string: Annotated[str, Query(
description="The string to search for."
description="The string to search for. Must be at least the configured minimum length "
"(see `minimum_query_length` in `/status`, default 2) after leading/trailing "
"whitespace is stripped; shorter queries are rejected with HTTP 422."
)],
autocomplete: Annotated[bool, Query(
description="Is the input string incomplete (autocomplete=true) or a complete phrase (autocomplete=false)?"
Expand Down Expand Up @@ -382,7 +421,7 @@ async def lookup_curies_post(
"""
Returns cliques with a name or synonym that contains a specified string.
"""
return await lookup(string, autocomplete, highlighting, offset, limit, biolink_type, only_prefixes, exclude_prefixes, only_taxa, debug)
return await lookup(string, autocomplete, highlighting, offset, limit, biolink_type, only_prefixes, exclude_prefixes, only_taxa, debug, raise_on_too_short=True)


async def lookup(string: str,
Expand All @@ -395,6 +434,7 @@ async def lookup(string: str,
exclude_prefixes: str = "",
only_taxa: str = "",
debug: DebugOptions = 'none',
raise_on_too_short: bool = False,
) -> List[LookupResult]:
"""
Returns cliques with a name or synonym that contains a specified string.
Expand All @@ -421,8 +461,14 @@ async def lookup(string: str,
# let's detect and replace just those characters.
string_lc = re.sub(r"[“”]", '"', re.sub(r"[‘’]", "'", string_lc))

# Do we have a search string at all?
if string_lc == "":
# Is the query long enough to be worth searching? Single-character queries are
# slow in Solr and never return useful results (see config.minimum_query_length).
if len(string_lc) < config.minimum_query_length:
if raise_on_too_short:
raise HTTPException(
status_code=422,
detail=f"Query string must be at least {config.minimum_query_length} character(s).",
)
return []

# For reasons I don't understand, we need to use backslash to escape characters (e.g. "\(") to remove the special
Expand Down Expand Up @@ -534,7 +580,7 @@ async def lookup(string: str,
logger.debug(f"Query: {json.dumps(params, indent=2)}")

time_solr_start = time.time_ns()
query_url = f"http://{SOLR_HOST}:{SOLR_PORT}/solr/name_lookup/select"
query_url = f"http://{config.solr_host}:{config.solr_port}/solr/name_lookup/select"
async with httpx.AsyncClient(timeout=None) as client:
response = await client.post(query_url, json=params)
if response.status_code >= 300:
Expand Down
12 changes: 9 additions & 3 deletions documentation/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Search for cliques by a fragment of a name or synonym.

**Parameters:**

- `string` (required, string): The string to search for.
- `string` (required, string): The string to search for. Must be at least the configured minimum length (see `minimum_query_length` in [`/status`](#status), default 2) after leading/trailing whitespace is stripped; shorter queries are rejected with HTTP 422.
- `autocomplete` (optional, boolean, default: false): If `true`, treats the input string as incomplete and looks for terms that start with the final word. If `false`, treats the entire phrase as complete (entity linker mode).
- `highlighting` (optional, boolean, default: false): If `true`, returns information on which labels and synonyms matched the search query.
- `offset` (optional, integer, default: 0, minimum: 0): The number of results to skip. Used for pagination.
Expand Down Expand Up @@ -191,7 +191,7 @@ Search for cliques for multiple strings in a single request.
- `strings` (required, list of strings): A list of strings to search for. The returned results will be in a dictionary with these values as keys.
- All other parameters are optional and apply to all searches.

**Returns:** A dictionary where each key is a string from the input `strings` array, and each value is a list of `LookupResult` objects (same structure as `/lookup` results).
**Returns:** A dictionary where each key is a string from the input `strings` array, and each value is a list of `LookupResult` objects (same structure as `/lookup` results). Unlike `/lookup`, a string shorter than the configured minimum length (see `minimum_query_length` in [`/status`](#status), default 2) is not rejected: it simply maps to an empty list, so one too-short string does not fail the whole batch.

**Example request:**

Expand Down Expand Up @@ -312,7 +312,10 @@ Returns the status of the service. Most importantly, this returns the [Babel](ht
version and changelog URL, which can be used to determine which version of Babel is currently loaded in this service.
It also includes the NameRes version (also visible in the OpenAPI documentation)
and the Biolink Model version used to build the Solr database, as well as bunch of information from the underlying
Solr database.
Solr database. The `config` object reports publicly-relevant configuration for this instance; currently just
`minimum_query_length`, the shortest query (in characters, after whitespace is stripped) that `/lookup` and
`/bulk-lookup` will search for. It defaults to 2 and can be set per deployment with the `NAMERES_MINIMUM_QUERY_LENGTH`
environment variable.

```json
{
Expand All @@ -326,6 +329,9 @@ Solr database.
"download_url": "https://raw.githubusercontent.com/biolink/biolink-model/v4.2.6-rc5/biolink-model.yaml"
},
"nameres_version": "v1.5.1",
"config": {
"minimum_query_length": 2
},
"startTime": "2025-12-19T11:53:09.638Z",
"numDocs": 425583391,
"maxDoc": 425586610,
Expand Down
34 changes: 31 additions & 3 deletions tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,39 @@ def test_simple_check():


def test_empty():
""" Checks that calling NameRes without an input string return an empty list. """
""" An empty (too-short) query on /lookup is rejected with HTTP 422. """
client = TestClient(app)
response = client.get("/lookup", params={'string':''})
syns = response.json()
assert len(syns) == 0
assert response.status_code == 422


def test_minimum_query_length():
"""
Queries shorter than config.minimum_query_length (default 2) are rejected on
/lookup with HTTP 422, but degrade to an empty list per-key on /bulk-lookup so
one short string doesn't fail the whole batch. The minimum is reported by /status.
"""
client = TestClient(app)

# A single character on /lookup is too short -> 422.
response = client.get("/lookup", params={'string': 'a'})
assert response.status_code == 422

# A valid two-character query is not rejected (goes to Solr, returns a 200 list).
response = client.get("/lookup", params={'string': 'ab'})
assert response.status_code == 200
assert isinstance(response.json(), list)

# On /bulk-lookup, a too-short string yields [] for its key; others resolve; request is 200.
response = client.post("/bulk-lookup", json={'strings': ['a', 'Parkinson'], 'limit': 100})
assert response.status_code == 200
results = response.json()
assert results['a'] == []
assert len(results['Parkinson']) == 34

# /status advertises the configured minimum under a nested config block.
response = client.get("/status")
assert response.json()['config'] == {'minimum_query_length': 2}


def test_limit():
Expand Down
Loading