From df864aba9b019958230710d2d68ee0fc1c6bf39c Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Tue, 1 Sep 2026 00:17:26 -0400 Subject: [PATCH 1/8] Nest the contact fields under info.contact in openapi.yml info carried loose email, name, x-id and x-role keys -- a contact block that was never nested -- so construct_open_api_schema() had no 'contact' to copy and the served spec has never had one. Nest them, matching the block NodeNorm serves at https://nodenormalization-sri.renci.org/openapi.json. Co-Authored-By: Claude Opus 5 --- api/resources/openapi.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/api/resources/openapi.yml b/api/resources/openapi.yml index f981ae4d..6b342a3d 100644 --- a/api/resources/openapi.yml +++ b/api/resources/openapi.yml @@ -2,10 +2,11 @@ openapi: 3.0.2 info: title: Name Resolver version: 1.7.0 - email: bizon@renci.org - name: Chris Bizon - x-id: https://github.com/cbizon - x-role: responsible developer + contact: + email: bizon@renci.org + name: Chris Bizon + x-id: https://github.com/cbizon + x-role: responsible developer description: 'Name Resolver (Name Lookup) service

This service takes lexical strings and attempts to map them to identifiers (CURIEs) from a vocabulary or ontology. An optional autocomplete mode (which assumes the query is incomplete) is available, as is an exact mode (which requires the whole string to match a name or synonym), along with many other options. From d26b291e83b9d1259558f462b236e7766ab7db8b Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Tue, 1 Sep 2026 00:17:26 -0400 Subject: [PATCH 2/8] Serve the custom OpenAPI schema by overriding app.openapi() Since FastAPI 0.137.0, openapi() rebuilds the schema whenever the app's recorded routes version doesn't match the router's current one. A schema assigned straight to app.openapi_schema never carries that stamp, so the first request to /openapi.json overwrote it with FastAPI's default document: the x-translator block SmartAPI registration keys off, termsOfService, tags and servers all vanished from the v1.7.0 spec without anything failing. Override the method instead, caching into app.openapi_schema on first use. That also makes construct_open_api_schema()'s "if app.openapi_schema: return app.openapi_schema()" reachable on the second request -- where it would call a dict -- so drop it; the caching now lives in the wrapper, which leaves the builder pure and callable from tests. Fixes #294. Co-Authored-By: Claude Opus 5 --- api/apidocs.py | 3 --- api/server.py | 17 +++++++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/api/apidocs.py b/api/apidocs.py index 9434b6b3..d509047c 100644 --- a/api/apidocs.py +++ b/api/apidocs.py @@ -35,9 +35,6 @@ def construct_open_api_schema(app) -> Dict[str, str]: with open(Path(__file__).parent / 'resources' / 'openapi.yml', 'r') as apd_file: api_docs = load(apd_file, Loader=SafeLoader) - if app.openapi_schema: - return app.openapi_schema() - open_api_schema = get_openapi( title=api_docs['info']['title'], version=api_docs['info']['version'], diff --git a/api/server.py b/api/server.py index 5c4c4258..96abb2a3 100755 --- a/api/server.py +++ b/api/server.py @@ -917,8 +917,21 @@ async def do_lookup(string: str): return result -# Override open api schema with custom schema -app.openapi_schema = construct_open_api_schema(app) +# Override the OpenAPI schema with the one we build from api/resources/openapi.yml. +# +# This has to replace the openapi() method rather than assign to 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 we assigned +# ourselves never carries that stamp -- so the first request to /openapi.json silently +# overwrote it with FastAPI's default document (issue #294). +def custom_openapi(): + """Build the custom OpenAPI schema once, then serve it from the cache.""" + if not app.openapi_schema: + app.openapi_schema = construct_open_api_schema(app) + return app.openapi_schema + + +app.openapi = custom_openapi # Set up opentelemetry if enabled. if os.environ.get('OTEL_ENABLED', 'false') == 'true': From b51ac3483776e947a99b657e462ed6e30ea50928 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Tue, 1 Sep 2026 00:17:26 -0400 Subject: [PATCH 3/8] Copy info.license into the served OpenAPI schema openapi.yml has declared an MIT license block all along, but construct_open_api_schema() copied everything except that, so the served spec never mentioned the license -- the same omission as contact, found while fixing #294. Co-Authored-By: Claude Opus 5 --- api/apidocs.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/apidocs.py b/api/apidocs.py index d509047c..63c15b76 100644 --- a/api/apidocs.py +++ b/api/apidocs.py @@ -50,6 +50,9 @@ def construct_open_api_schema(app) -> Dict[str, str]: if 'contact' in api_docs['info']: open_api_schema['info']['contact'] = api_docs['info']['contact'] + if 'license' in api_docs['info']: + open_api_schema['info']['license'] = api_docs['info']['license'] + if 'termsOfService' in api_docs['info']: open_api_schema['info']['termsOfService'] = api_docs['info']['termsOfService'] From 3171798fb5a3ba3301566bbe2981eb55d4f31c59 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Tue, 1 Sep 2026 00:17:37 -0400 Subject: [PATCH 4/8] Add tests for the OpenAPI document served at /openapi.json There was no test covering the served spec, which is why a whole-block regression shipped in v1.7.0. These go through TestClient on purpose: asserting on construct_open_api_schema() directly passes throughout the bug, because the builder kept returning the right document while FastAPI served its own instead. The environment-override test calls the builder rather than the route, since the served schema is cached after the first request and cannot see an environment changed later. Co-Authored-By: Claude Opus 5 --- tests/test_openapi.py | 67 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/test_openapi.py diff --git a/tests/test_openapi.py b/tests/test_openapi.py new file mode 100644 index 00000000..e0c59ed4 --- /dev/null +++ b/tests/test_openapi.py @@ -0,0 +1,67 @@ +""" +Tests for the OpenAPI document served at /openapi.json. + +These go through TestClient on purpose. The regression in issue #294 was invisible to +any test that called construct_open_api_schema() directly: the builder kept returning +the right document throughout, while FastAPI served its own default one instead. +""" + +from fastapi.testclient import TestClient + +from api.apidocs import construct_open_api_schema +from api.server import app + + +def test_openapi_json_carries_translator_metadata(): + """The served spec must carry what openapi.yml declares, not FastAPI's default document.""" + openapi = TestClient(app).get("/openapi.json").json() + info = openapi["info"] + + # x-translator.infores is what SmartAPI registration keys off: without it, the + # deployment looks like an unregistered service. + assert info["x-translator"]["infores"] == "infores:sri-name-resolver" + assert info["x-translator"]["component"] == "Utility" + assert info["x-translator"]["team"] + assert info["termsOfService"] + assert info["license"]["name"] + assert info["contact"]["email"] + assert info["contact"]["name"] + assert info["description"] + assert openapi["servers"] + assert openapi["tags"] + + # Every server needs the maturity and location values ITRB sets. + for server in openapi["servers"]: + assert server["x-maturity"] + assert server["x-location"] + + +def test_openapi_json_is_stable_across_requests(): + """Every request must get the same custom schema, not just the first one.""" + client = TestClient(app) + + first = client.get("/openapi.json") + second = client.get("/openapi.json") + + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json() == second.json() + + +def test_server_metadata_respects_environment(monkeypatch): + """SERVER_ROOT, MATURITY_VALUE and LOCATION_VALUE override the servers block. + + This calls the builder rather than the route because the served schema is cached + after the first request, so it can't see an environment changed later. + """ + monkeypatch.setenv("SERVER_ROOT", "/nameres") + monkeypatch.setenv("MATURITY_VALUE", "testing") + monkeypatch.setenv("LOCATION_VALUE", "RENCI") + + servers = construct_open_api_schema(app)["servers"] + + assert servers + for server in servers: + assert server["url"] == "/nameres/" + assert server["x-maturity"] == "testing" + assert server["x-location"] == "RENCI" From c53328f9d207e3d7fbe6443e50c38aed3daa699b Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Tue, 1 Sep 2026 00:17:37 -0400 Subject: [PATCH 5/8] Pin fastapi to ~=0.141.1 fastapi was unpinned, so the image built for v1.7.0 picked up 0.137.0 and lost the custom OpenAPI metadata silently. Pin it so an unrelated rebuild cannot move the stack underneath us again. Verified on Python 3.11 against the existing opentelemetry pins: the suite passes, and with OTEL_ENABLED=true the FastAPI and httpx instrumentation still works under the starlette 1.6.0 that 0.141.1 pulls in. Co-Authored-By: Claude Opus 5 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2fcfc546..e814927f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ requests -fastapi +fastapi~=0.141.1 httpx uvicorn pyyaml From 24e32e15223ba2202f6c928fc0170df145eb0cf1 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Tue, 1 Sep 2026 00:17:37 -0400 Subject: [PATCH 6/8] Record the app.openapi override as a gotcha The failure mode is silent -- the service starts, answers queries and serves a valid-but-wrong spec -- so note both halves: install the custom document by overriding the method, and test it through the route. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3487a982..e58a4e1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,7 @@ Solr documents contain: `curie`, `preferred_name`, `names` (synonym list), and b - **The empty-query rejection is not the same rule as `minimum_query_length`.** An empty string reaches Solr as `"" OR ()`, which is a parse error and therefore an HTTP 500 for what is really an empty search box, so `lookup()` floors its length check at 1 (`max(1, config.minimum_query_length)`) rather than deriving it from the setting alone. Folding the two together breaks the moment someone sets `NAMERES_MINIMUM_QUERY_LENGTH=0` to turn the minimum off. Exact mode is exempt from the setting but not from the floor. - **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. ## Documentation - `documentation/API.md` - Endpoint reference From 877f49a719437a69bd2a505c046d7e9c0151d4da Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Tue, 1 Sep 2026 00:51:03 -0400 Subject: [PATCH 7/8] Note that openapi.yml keys are served only if the builder copies them construct_open_api_schema() works off an allowlist, so contact and license were declared in openapi.yml for years without ever reaching the served spec. The silence is the trap worth writing down. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index e58a4e1a..13a2308b 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. +- **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 - `documentation/API.md` - Endpoint reference From 4992e7c6baf6fc09f72138837a6b530d2c3f6649 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Tue, 1 Sep 2026 00:55:40 -0400 Subject: [PATCH 8/8] Parse openapi.yml once instead of once per caller get_app_info() and construct_open_api_schema() each opened and parsed the file separately, the former on every /status request. Read it behind an lru_cache and hand out a deepcopy: construct_open_api_schema() rewrites the servers block in place from the environment, so sharing the cached parse would let one build reach back and edit a document already served to somebody. The new test fails if the copy is dropped. Co-Authored-By: Claude Opus 5 --- api/apidocs.py | 29 +++++++++++++++++++++++++---- tests/test_openapi.py | 17 +++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/api/apidocs.py b/api/apidocs.py index 63c15b76..d1ee3815 100644 --- a/api/apidocs.py +++ b/api/apidocs.py @@ -3,6 +3,8 @@ """ import os +from copy import deepcopy +from functools import lru_cache from pathlib import Path from typing import Dict @@ -10,12 +12,32 @@ from fastapi.openapi.utils import get_openapi +@lru_cache(maxsize=1) +def _parse_api_docs() -> Dict: + """ + Parse openapi.yml. Cached, since the file can't change under a running process and + get_app_info() is called on every /status request. + """ + with open(Path(__file__).parent / 'resources' / 'openapi.yml', 'r') as apd_file: + return load(apd_file, Loader=SafeLoader) + + +def load_api_docs() -> Dict: + """ + A fresh copy of the parsed openapi.yml. + + Callers mutate what they take out of this -- construct_open_api_schema() rewrites the + servers block from the environment -- so they must not share the cached parse, or one + call would edit a document already handed to someone else. + """ + return deepcopy(_parse_api_docs()) + + def get_app_info() -> Dict[str, str]: """ Get title, version, description from openapi.yml """ - with open(Path(__file__).parent / 'resources' / 'openapi.yml', 'r') as apd_file: - api_docs = load(apd_file, Loader=SafeLoader) + api_docs = load_api_docs() return { k : v for k,v in api_docs['info'].items() if k in [ @@ -32,8 +54,7 @@ def construct_open_api_schema(app) -> Dict[str, str]: https://fastapi.tiangolo.com/advanced/extending-openapi/ """ - with open(Path(__file__).parent / 'resources' / 'openapi.yml', 'r') as apd_file: - api_docs = load(apd_file, Loader=SafeLoader) + api_docs = load_api_docs() open_api_schema = get_openapi( title=api_docs['info']['title'], diff --git a/tests/test_openapi.py b/tests/test_openapi.py index e0c59ed4..c6aff6d5 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -65,3 +65,20 @@ def test_server_metadata_respects_environment(monkeypatch): assert server["url"] == "/nameres/" assert server["x-maturity"] == "testing" assert server["x-location"] == "RENCI" + + +def test_building_the_schema_twice_leaves_the_first_alone(monkeypatch): + """openapi.yml is parsed once and cached, so each build must get its own copy. + + construct_open_api_schema() rewrites the servers block in place from the + environment; if it worked on the cached parse, a later build would reach back and + edit a document already served to somebody. + """ + monkeypatch.setenv("MATURITY_VALUE", "development") + first = construct_open_api_schema(app) + + monkeypatch.setenv("MATURITY_VALUE", "production") + second = construct_open_api_schema(app) + + assert first["servers"][0]["x-maturity"] == "development" + assert second["servers"][0]["x-maturity"] == "production"