Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
53cfad2
Rewrite the OpenAPI document to describe the Elasticsearch service
gaurav Oct 5, 2026
35c16a3
Make two endpoints answer the way the OpenAPI document says
gaurav Oct 12, 2026
8f6bc15
Test that the OpenAPI document matches the handlers
gaurav Oct 5, 2026
5de9ee9
Document where the OpenAPI document lives and how it is kept in sync
gaurav Oct 5, 2026
10df398
Unit-test the semantic-type casing fix
gaurav Oct 12, 2026
77b9f4f
Match /openapi.json and /favicon.ico literally
gaurav Oct 12, 2026
e6a1673
Check POST bodies and helper-read parameters against the OpenAPI docu…
gaurav Oct 12, 2026
9bfd367
Return the set-ID error to the client instead of a 500
gaurav Oct 12, 2026
bd59e3d
Reject a POST /get_setid body that isn't an array of sets
gaurav Oct 12, 2026
0e5bdeb
Report status=error from /status when Elasticsearch is down
gaurav Oct 12, 2026
5701a92
Match API routes exactly
gaurav Oct 12, 2026
b4f22fe
Scan the whole handler for POST body fields
gaurav Oct 12, 2026
6257de3
Document HEAD /get_allowed_conflations and check every method
gaurav Oct 12, 2026
48a4568
Declare jsonschema as a test dependency
gaurav Oct 12, 2026
8649756
Reject /get_normalized_nodes flags that aren't booleans
gaurav Oct 12, 2026
d11a8ad
Test the empty-set early return in generate_setid()
gaurav Oct 12, 2026
e409ac9
Explain why the route patterns have no trailing ?
gaurav Oct 12, 2026
0ed1dab
Use Will Liu's Scripps address as the API contact
gaurav Oct 12, 2026
c966c44
List the production deployment in the OpenAPI servers
gaurav Oct 12, 2026
4b4db66
Say what the semantic-types check in the API description is for
gaurav Oct 12, 2026
ba34f93
Answer 400, not 500, to a POST /get_setid body that isn't UTF-8
gaurav Oct 14, 2026
037ace3
Answer 400, not 500, to a malformed POST /get_normalized_nodes body
gaurav Oct 14, 2026
0beea06
Validate the OpenAPI document as the server serves it
gaurav Oct 14, 2026
3d000cc
Identify the service as infores:nodenorm
gaurav Oct 15, 2026
3c460d5
Merge main into OpenAPI update and preserve current API contracts
shuchenliu Nov 2, 2026
0c36703
Change type field to accept null in openapi.json
shuchenliu Nov 2, 2026
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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ index names:
{
"status": "running",
"deployment_environment": "CI",
"version": "1.1.0",
"version": "1.2.0",
"babel_version": "2026jul22",
"babel_version_url": "https://github.com/NCATSTranslator/Babel/blob/main/releases/2026jul22/README.md",
"backend": "elasticsearch",
Expand Down Expand Up @@ -186,3 +186,18 @@ success and **1 second** on failure, with concurrent requests sharing one check
the next status request to use a new health check. A failed check returns HTTP 503;
an expired healthy result is never served after a failed check. Health checks do not
trigger Babel discovery. All status responses send `Cache-Control: no-store`.

## OpenAPI document

API version 1.2.0 corrects conflation and semantic-type responses and rejects invalid boolean
flags. It retains the multi-Babel selectors, response headers and status health checks added in 1.1.0.

The API is described by `src/nodenorm/webapp/openapi.json`, which is written by hand and served
verbatim at `/webapp/openapi.json` (with `/openapi.json` redirecting there); the Swagger UI at `/`
renders it. `/status` reports its `info.version` as the API version, so bump that when the API
changes. `tests/test_openapi.py` checks that the document validates and that its paths, methods,
query parameters, POST body fields and the `SetIDResponse` schema match the handlers, so a handler
change that isn't reflected in the JSON fails the test suite. The parameter and body checks find
what each HTTP method reads by inspecting literal argument names in Tornado `get_argument(s)` and
`get_query_argument(s)` calls, and literal body fields read with `.get("…")`. They follow handler
helper methods and resolve local parameter references. Dynamic names still need explicit coverage.
2 changes: 1 addition & 1 deletion docker/configuration/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@
"OPENTELEMETRY_SERVICE_NAME": "NodeNorm",
"OPENTELEMETRY_JAEGER_HOST": "http://jaeger-otel-collector.sri",
"OPENTELEMETRY_JAEGER_PORT": 4318,
"OPENTELEMETRY_EXCLUDED_URLS": ["^/$", "^/status$", "^/version$", "^/webapp", "^/favicon\\.ico$"]
"OPENTELEMETRY_EXCLUDED_URLS": ["^/$", "^/status$", "^/version$", "^/webapp", "^/openapi\\.json$", "^/favicon\\.ico$"]
}
}
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ tests = [
"pytest >= 8.1.1",
"pytest-asyncio >= 0.23.8",
"pytest-randomly",
"openapi-spec-validator",
"jsonschema",
]

telemetry = [
Expand Down
2 changes: 1 addition & 1 deletion src/nodenorm/config/config.default.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@
"OPENTELEMETRY_SERVICE_NAME": "NodeNorm",
"OPENTELEMETRY_JAEGER_HOST": "http://localhost",
"OPENTELEMETRY_JAEGER_PORT": 4318,
"OPENTELEMETRY_EXCLUDED_URLS": ["^/$", "^/status$", "^/version$", "^/webapp", "^/favicon\\.ico$"]
"OPENTELEMETRY_EXCLUDED_URLS": ["^/$", "^/status$", "^/version$", "^/webapp", "^/openapi\\.json$", "^/favicon\\.ico$"]
}
}
18 changes: 12 additions & 6 deletions src/nodenorm/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ def build_handlers() -> dict[str, tuple[str, Callable]]:
from nodenorm.handlers.set_identifiers import SetIdentifierHandler
from nodenorm.handlers.version import VersionHandler

# Patterns are regexes matched against the path alone (query strings never reach them), so
# don't end one with "?": that makes its last character optional. Escape any literal dot.
handler_collection = [
(r"/get_allowed_conflations?", ValidConflationsHandler),
(r"/get_normalized_nodes?", NormalizedNodesHandler),
(r"/get_semantic_types?", SemanticTypeHandler),
(r"/get_setid?", SetIdentifierHandler),
(r"/status?", NodeNormHealthHandler),
(r"/get_allowed_conflations", ValidConflationsHandler),
(r"/get_normalized_nodes", NormalizedNodesHandler),
(r"/get_semantic_types", SemanticTypeHandler),
(r"/get_setid", SetIdentifierHandler),
(r"/status", NodeNormHealthHandler),
(r"/version", VersionHandler),
]
# build static file frontend
Expand All @@ -42,8 +44,12 @@ def build_handlers() -> dict[str, tuple[str, Callable]]:
handler_collection.append(index_handler)

# This redirect ensures we default so the favicon icon can be found in the webapp directory
favicon_handler = (r"/favicon.ico", tornado.web.RedirectHandler, {"url": "/webapp/swaggerui/favicon-32x32.png"})
favicon_handler = (r"/favicon\.ico", tornado.web.RedirectHandler, {"url": "/webapp/swaggerui/favicon-32x32.png"})
handler_collection.append(favicon_handler)

# NodeNorm Redis (FastAPI) serves its OpenAPI document at the root; keep that address working
openapi_handler = (r"/openapi\.json", tornado.web.RedirectHandler, {"url": "/webapp/openapi.json"})
handler_collection.append(openapi_handler)

handlers = {handler[0]: handler for handler in handler_collection}
return handlers
12 changes: 12 additions & 0 deletions src/nodenorm/handlers/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Shared handler behavior for NodeNormalization API endpoints."""

import json

from biothings.web.handlers import BaseHandler
from tornado.web import HTTPError

Expand Down Expand Up @@ -41,3 +43,13 @@ def select_babel_release(self, version: str = None) -> BabelRelease:

def options(self, *args, **kwargs):
self.finish()

def load_json_body(self):
"""Parse the request body as JSON, answering 400 rather than 500 if it can't be.

json.loads() raises UnicodeDecodeError, not JSONDecodeError, for bytes that aren't valid UTF-8.
"""
try:
return json.loads(self.request.body)
except (json.JSONDecodeError, UnicodeDecodeError) as error:
raise HTTPError(detail=f"The body is not valid JSON: {error}", status_code=400)
11 changes: 4 additions & 7 deletions src/nodenorm/handlers/conflations.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import logging

from nodenorm.handlers.base import NodeNormalizationBaseHandler

logger = logging.getLogger(__name__)
ALLOWED_CONFLATIONS = ["GeneProtein", "DrugChemical"]


class ValidConflationsHandler(NodeNormalizationBaseHandler):
name = "allowed-conflations"

async def get(self):
conflations = ["GeneProtein", "DrugChemical"]
self.finish(conflations)
# Wrapped in an object, as NodeNorm Redis does. Tornado also refuses to write a bare list.
self.finish({"conflations": ALLOWED_CONFLATIONS})

async def head(self):
conflations = ["GeneProtein", "DrugChemical"]
self.finish(conflations)
await self.get()
63 changes: 0 additions & 63 deletions src/nodenorm/handlers/curie_prefix.py

This file was deleted.

53 changes: 35 additions & 18 deletions src/nodenorm/handlers/normalized_nodes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import dataclasses
import json
import logging
import time
from typing import Optional, Union
Expand All @@ -25,6 +24,25 @@ class NormalizedNode:
taxa: list[str]


# The strings FastAPI (Pydantic) accepted as booleans in NodeNorm Redis, which this API mirrors.
TRUE_STRINGS = {"true", "1", "yes", "on", "t", "y"}
FALSE_STRINGS = {"false", "0", "no", "off", "f", "n"}


def parse_boolean(name: str, argument: Union[str, bool]) -> bool:
"""Read a boolean flag, rejecting values that aren't clearly true or false with HTTP 400."""
if isinstance(argument, bool):
return argument
if isinstance(argument, str):
if argument.lower() in TRUE_STRINGS:
return True
if argument.lower() in FALSE_STRINGS:
return False
raise HTTPError(
detail=f"{name} must be true or false (or 1/0, yes/no, on/off, t/f, y/n), not {argument!r}", status_code=400
)


class NormalizedNodesHandler(NodeNormalizationBaseHandler):
"""
Mirror implementation to the renci implementation found at
Expand All @@ -42,17 +60,12 @@ async def get(self):
detail="Missing curie argument, there must be at least one curie to normalize", status_code=400
)

def parse_boolean(argument: Union[str, bool]) -> bool:
if isinstance(argument, bool):
return argument
if isinstance(argument, str):
return not argument.lower() == "false"
return False

conflate = parse_boolean(self.get_argument("conflate", True))
drug_chemical_conflate = parse_boolean(self.get_argument("drug_chemical_conflate", False))
description = parse_boolean(self.get_argument("description", False))
individual_types = parse_boolean(self.get_argument("individual_types", False))
conflate = parse_boolean("conflate", self.get_argument("conflate", True))
drug_chemical_conflate = parse_boolean(
"drug_chemical_conflate", self.get_argument("drug_chemical_conflate", False)
)
description = parse_boolean("description", self.get_argument("description", False))
individual_types = parse_boolean("individual_types", self.get_argument("individual_types", False))
release = self.select_babel_release(self.get_query_argument("babel_version", None))

normalized_nodes = await get_normalized_nodes(
Expand All @@ -78,7 +91,7 @@ async def post(self):

Example body
{
"curie": [
"curies": [
"MESH:D014867",
"NCIT:C34373"
]
Expand Down Expand Up @@ -134,17 +147,21 @@ async def post(self):
}
}
"""
post_body: dict = json.loads(self.request.body)
post_body: dict = self.load_json_body()
if not isinstance(post_body, dict):
raise HTTPError(
detail='The body must be a JSON object, such as {"curies": ["MESH:D014867"]}', status_code=400
)
normalization_curies = post_body.get("curies", [])
if len(normalization_curies) == 0:
raise HTTPError(
detail="Missing curie argument, there must be at least one curie to normalize", status_code=400
)

conflate = post_body.get("conflate", True)
drug_chemical_conflate = post_body.get("drug_chemical_conflate", False)
description = post_body.get("description", False)
individual_types = post_body.get("individual_types", False)
conflate = parse_boolean("conflate", post_body.get("conflate", True))
drug_chemical_conflate = parse_boolean("drug_chemical_conflate", post_body.get("drug_chemical_conflate", False))
description = parse_boolean("description", post_body.get("description", False))
individual_types = parse_boolean("individual_types", post_body.get("individual_types", False))
babel_version = post_body.get("babel_version")
if "babel_version" in post_body and not isinstance(babel_version, str):
supported = ", ".join(self.biothings.elasticsearch.babel.available_versions)
Expand Down
2 changes: 1 addition & 1 deletion src/nodenorm/handlers/semantic_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async def get(self) -> dict:
biolink_type = bucket["key"]
semantic_types.add(biolink_type)
for ancestor in toolkit.get_ancestors(biolink_type):
semantic_types.add(toolkit.get_element(ancestor)["class_uri"].lower())
semantic_types.add(toolkit.get_element(ancestor)["class_uri"])

semantic_type_response = {"semantic_types": {"types": list(semantic_types)}}
self.finish(semantic_type_response)
14 changes: 9 additions & 5 deletions src/nodenorm/handlers/set_identifiers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# set_id.py
# Code related to generating IDs for sets (as in https://github.com/TranslatorSRI/NodeNormalization/issues/256).
import dataclasses
import json
import logging
import uuid
from typing import Optional
Expand Down Expand Up @@ -57,7 +56,12 @@ async def get(self):
self.finish(set_identifiers)

async def post(self):
post_body: list[dict] = json.loads(self.request.body)
post_body: list[dict] = self.load_json_body()
if not isinstance(post_body, list) or not all(isinstance(group, dict) for group in post_body):
raise HTTPError(
detail='The body must be a JSON array of sets, such as [{"curies": ["MESH:D014867"]}]',
status_code=400,
)
if len(post_body) == 0:
raise HTTPError(
detail="Missing JSON body, there must be at least one curie to generate a set identifier",
Expand Down Expand Up @@ -95,7 +99,7 @@ async def generate_setid(
:param curies: A list of curies to generate a set ID for.
:param conflations: A list of conflations to apply. Must be one or both of 'GeneProtein' and 'DrugChemical'.
:param search_indices: Request-selected Elasticsearch targets; omitted for the deployment default.
:return: A SetIDResponse with the Set ID.
:return: A SetIDResponse with the Set ID, as a dict: Tornado can only write dicts, not dataclasses.
"""

# Step 0. Prepare the SetIDResponse by filling it with the arguments.
Expand All @@ -109,7 +113,7 @@ async def generate_setid(
"Conflations provided to "
+ f"generate_setid() are {conflations}, but only 'GeneProtein' and 'DrugChemical' are allowed."
)
return response
return dataclasses.asdict(response)

# We use get_normalized_nodes() to normalize all the CURIEs for us.
normalization_results = await get_normalized_nodes(
Expand Down Expand Up @@ -156,7 +160,7 @@ async def generate_setid(

# Do we have any normalized CURIEs? If not, return now.
if not sorted_normalized_curies:
return response
return dataclasses.asdict(response)

normalized_string = "||".join(sorted_normalized_curies)
response.normalized_string = normalized_string
Expand Down
Loading