diff --git a/CLAUDE.md b/CLAUDE.md index 13a2308b..c317fe87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,7 @@ Solr documents contain: `curie`, `preferred_name`, `names` (synonym list), and b - **Do not declare a custom `responses={422: ...}` on an endpoint.** FastAPI adds the `HTTPValidationError` body only when the operation has not already declared a 422 of its own, so a hand-written one silently strips the schema and leaves client generators with an untyped error. For the same reason, `lookup()` reports a too-short query with `RequestValidationError`, not `HTTPException(422)`: the latter returns `{"detail": ""}` where FastAPI's own validation returns `{"detail": [...]}`. `tests/test_service.py` pins both. - **Query-side string normalization must not be applied to exact matching.** The `*_exactish` fields are a KeywordTokenizer plus a LowerCaseFilter and fold nothing else, so the smart-quote rewrite (and anything like it) would search for a string the caller never typed. The default path is unaffected because StandardTokenizer discards the punctuation anyway. - **The custom OpenAPI document must be installed by overriding `app.openapi`, not by assigning `app.openapi_schema`.** Since FastAPI 0.137.0, `openapi()` rebuilds the schema whenever the app's recorded routes version doesn't match the router's current one, and a schema assigned directly to the attribute never carries that stamp -- so FastAPI quietly overwrites it on the first request to `/openapi.json` and serves its default document, losing `info.x-translator` (which is what SmartAPI registration keys off), `contact`, `termsOfService`, `tags` and `servers`. It fails open, so nothing but the served spec shows it: that is how v1.7.0 shipped it (issue #294). `tests/test_openapi.py` pins this, and has to go through `TestClient` -- asserting on `construct_open_api_schema()` directly passes throughout the bug. `fastapi` is pinned in `requirements.txt` for the same reason. +- **`/status`'s `version` is the API version, not the Solr index version.** The Solr index version is `index_version`; it was called `version` until #296, which handed that key to the OpenAPI `info.version` so that NameRes and NodeNorm spell the API version the same way. `backend` is always `"solr"` here, and exists only to tell this implementation apart from the Elasticsearch-backed NameRes at biothings/NameResolutionAPI -- before it, the two differed only by a word of prose in `message`, which is a worse thing for a downstream check to depend on. Both branches of `status()` must report `backend` and `version` (a mis-pointed deployment returning `Expected core not found.` is exactly when a caller needs them), which is why the shared keys are built once as `common` and spread into each return. - **Declaring metadata in `openapi.yml` is not enough to serve it.** `construct_open_api_schema()` copies an explicit allowlist of `info` keys into the document (and `get_app_info()` a narrower one for the `FastAPI()` constructor); anything not named there is dropped without a word. That is how `info.contact` and `info.license` sat declared-but-unserved for years. Adding a key means adding it to the copy list *and* asserting it in `tests/test_openapi.py`. ## Documentation diff --git a/api/server.py b/api/server.py index 96abb2a3..cf650cd7 100755 --- a/api/server.py +++ b/api/server.py @@ -128,11 +128,12 @@ async def status() -> Dict: biolink_model_url = f"https://github.com/biolink/biolink-model/tree/{biolink_model_tag}" biolink_model_download_url = f"https://raw.githubusercontent.com/biolink/biolink-model/{biolink_model_tag}/biolink-model.yaml" - # Figure out the NameRes version. - nameres_version = "master" - app_info = get_app_info() - if 'version' in app_info and app_info['version']: - nameres_version = 'v' + app_info['version'] + # Figure out the NameRes version. We report it twice, deliberately: `version` is the + # OpenAPI info.version verbatim, so that it can be compared against /openapi.json and + # against NodeNorm's /status, which reports the same key the same way (issue #296); + # `nameres_version` is the older 'v'-prefixed spelling, kept for existing consumers. + openapi_version = get_app_info().get('version') or '' + nameres_version = 'v' + openapi_version if openapi_version else 'master' # We should have a status for our core. Standalone Solr calls it $SOLR_CORE # (name_lookup); the older cloud-mode backups called it @@ -143,6 +144,29 @@ async def status() -> Dict: if core is None and len(cores) == 1: core = next(iter(cores.values())) + # The fields both branches below report. Built once and spread into each, so that the + # two can't drift: the error branch is what a mis-pointed or still-loading deployment + # returns, and that is exactly the case in which a caller most needs to know which + # NameRes it reached. + common = { + # Always 'solr' from this implementation. The field exists so that callers can + # tell us apart from the Elasticsearch-backed NameRes at + # biothings/NameResolutionAPI, which reports 'elasticsearch' here; until it + # existed the only difference between the two was a word of prose in `message` + # (issue #296). + 'backend': 'solr', + 'version': openapi_version or 'unknown', + 'babel_version': babel_version, + 'babel_version_url': babel_version_url, + 'biolink_model': { + 'tag': biolink_model_tag, + 'url': biolink_model_url, + 'download_url': biolink_model_download_url, + }, + 'nameres_version': nameres_version, + 'config': config.public(), + } + if core is not None: index = {} if 'index' in core: @@ -151,15 +175,7 @@ async def status() -> Dict: return { 'status': 'ok', 'message': 'Reporting results from primary core.', - 'babel_version': babel_version, - 'babel_version_url': babel_version_url, - 'biolink_model': { - 'tag': biolink_model_tag, - 'url': biolink_model_url, - 'download_url': biolink_model_download_url, - }, - 'nameres_version': nameres_version, - 'config': config.public(), + **common, # .get() rather than [], like every field below it: Solr's core STATUS # returns a sparse entry for a core that is still initializing, and # /status is what the Kubernetes probes call. A KeyError here would turn @@ -168,7 +184,9 @@ async def status() -> Dict: 'numDocs': index.get('numDocs', ''), 'maxDoc': index.get('maxDoc', ''), 'deletedDocs': index.get('deletedDocs', ''), - 'version': index.get('version', ''), + # The Solr index version. Called 'version' until #296, which handed that key + # to the API version so that NameRes and NodeNorm spell it the same way. + 'index_version': index.get('version', ''), 'segmentCount': index.get('segmentCount', ''), 'lastModified': index.get('lastModified', ''), 'size': index.get('size', ''), @@ -177,15 +195,7 @@ async def status() -> Dict: return { 'status': 'error', 'message': 'Expected core not found.', - 'babel_version': babel_version, - 'babel_version_url': babel_version_url, - 'biolink_model': { - 'tag': biolink_model_tag, - 'url': biolink_model_url, - 'download_url': biolink_model_download_url, - }, - 'nameres_version': nameres_version, - 'config': config.public(), + **common, } diff --git a/documentation/API.md b/documentation/API.md index 9de44d9a..b4f5a14a 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -348,7 +348,21 @@ 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. The `config` object reports publicly-relevant configuration for this instance; currently just +Solr database. + +`backend` names the database answering the query. It is always `"solr"` from this implementation; the +Elasticsearch-backed NameRes at [biothings/NameResolutionAPI](https://github.com/biothings/NameResolutionAPI) reports +`"elasticsearch"`. Downstream tools should read this field rather than the human-readable `message` to tell the two +apart. + +`version` is the version of this API, identical to `info.version` in the service's `/openapi.json` document and +reported under the same key by [NodeNorm's `/status`](https://github.com/NCATSTranslator/NodeNormalization). +`nameres_version` is the same value with a leading `v`, and is retained for consumers that already read it. + +`index_version` is the Solr index version. **This field was called `version` before NameRes v1.8.0**, and was renamed +so that `version` could carry the API version under the key NodeNorm already uses. + +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 in the default tokenized search. It defaults to 2 and can be set per deployment with the `NAMERES_MINIMUM_QUERY_LENGTH` environment variable. It does not constrain [exact matching](#exact-matching), which @@ -358,6 +372,8 @@ accepts any non-empty string. { "status": "ok", "message": "Reporting results from primary core.", + "backend": "solr", + "version": "1.7.0", "babel_version": "2025sep1", "babel_version_url": "https://github.com/ncatstranslator/Babel/blob/master/releases/2025sep1.md", "biolink_model": { @@ -365,7 +381,7 @@ accepts any non-empty string. "url": "https://github.com/biolink/biolink-model/tree/v4.2.6-rc5", "download_url": "https://raw.githubusercontent.com/biolink/biolink-model/v4.2.6-rc5/biolink-model.yaml" }, - "nameres_version": "v1.5.1", + "nameres_version": "v1.7.0", "config": { "minimum_query_length": 2 }, @@ -373,7 +389,7 @@ accepts any non-empty string. "numDocs": 425583391, "maxDoc": 425586610, "deletedDocs": 3219, - "version": 34838, + "index_version": 34838, "segmentCount": 57, "lastModified": "2025-09-24T19:09:56.524Z", "size": "142.17 GB" diff --git a/tests/test_status.py b/tests/test_status.py index b48ddc07..6756239e 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -18,7 +18,22 @@ def test_status(): assert 'biolink_model' in status assert 'tag' in status['biolink_model'] assert 'nameres_version' in status - assert status['version'] > 1 + + # Issue #296: `backend` tells this Solr implementation apart from the + # Elasticsearch-backed NameRes (biothings/NameResolutionAPI), which reports + # 'elasticsearch' here. + assert status['backend'] == 'solr' + + # `version` is the API version, and must be identical to the version in the served + # OpenAPI document -- that identity is the whole point of the field, so it is pinned + # against /openapi.json rather than against a literal that would need editing on + # every version bump. + assert status['version'] == client.get("/openapi.json").json()['info']['version'] + assert status['nameres_version'] == 'v' + status['version'] + + # The Solr index version, called 'version' until #296. + assert status['index_version'] > 1 + assert status['size'] != '' assert status['startTime']