From 196cd67aaed868d507a909ef8ed4902dc7e892cd Mon Sep 17 00:00:00 2001 From: shuchenliu Date: Sat, 16 May 2026 14:28:16 -0700 Subject: [PATCH 1/5] Fix CORS preflight handling --- src/nodenorm/handlers/base.py | 27 ++++++++++ src/nodenorm/handlers/conflations.py | 5 +- src/nodenorm/handlers/curie_prefix.py | 4 +- src/nodenorm/handlers/health.py | 5 +- src/nodenorm/handlers/normalized_nodes.py | 4 +- src/nodenorm/handlers/semantic_types.py | 4 +- src/nodenorm/handlers/set_identifiers.py | 5 +- src/nodenorm/handlers/version.py | 5 +- src/nodenorm/namespace.py | 15 +++--- tests/test_cors.py | 64 +++++++++++++++++++++++ 10 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 src/nodenorm/handlers/base.py create mode 100644 tests/test_cors.py diff --git a/src/nodenorm/handlers/base.py b/src/nodenorm/handlers/base.py new file mode 100644 index 0000000..1d2c254 --- /dev/null +++ b/src/nodenorm/handlers/base.py @@ -0,0 +1,27 @@ +"""Shared handler behavior for NodeNormalization API endpoints.""" + +from biothings.web.handlers import BaseHandler + + +class NodeNormalizationBaseHandler(BaseHandler): + """Base handler that keeps the lightweight BioThings handler plus CORS.""" + + cors_methods = "GET, POST, HEAD, OPTIONS" + cors_max_age = "600" + + def set_default_headers(self): + origin = self.request.headers.get("Origin") + if origin is None: + return + + requested_headers = self.request.headers.get("Access-Control-Request-Headers") + + self.set_header("Access-Control-Allow-Origin", origin) + self.set_header("Access-Control-Allow-Credentials", "true") + self.set_header("Access-Control-Allow-Methods", self.cors_methods) + self.set_header("Access-Control-Allow-Headers", requested_headers or "*") + self.set_header("Access-Control-Max-Age", self.cors_max_age) + self.set_header("Vary", "Origin") + + def options(self, *args, **kwargs): + self.finish() diff --git a/src/nodenorm/handlers/conflations.py b/src/nodenorm/handlers/conflations.py index 0901152..62cb57e 100644 --- a/src/nodenorm/handlers/conflations.py +++ b/src/nodenorm/handlers/conflations.py @@ -1,12 +1,11 @@ import logging -from biothings.web.handlers import BaseHandler - +from nodenorm.handlers.base import NodeNormalizationBaseHandler logger = logging.getLogger(__name__) -class ValidConflationsHandler(BaseHandler): +class ValidConflationsHandler(NodeNormalizationBaseHandler): name = "allowed-conflations" async def get(self): diff --git a/src/nodenorm/handlers/curie_prefix.py b/src/nodenorm/handlers/curie_prefix.py index e4a4dc6..e722d4e 100644 --- a/src/nodenorm/handlers/curie_prefix.py +++ b/src/nodenorm/handlers/curie_prefix.py @@ -1,10 +1,10 @@ -from biothings.web.handlers import BaseHandler from tornado.web import HTTPError from nodenorm.biolink import toolkit +from nodenorm.handlers.base import NodeNormalizationBaseHandler -class SemanticTypeHandler(BaseHandler): +class SemanticTypeHandler(NodeNormalizationBaseHandler): """ Mirror implementation to the renci implementation found at https://nodenormalization-sri.renci.org/docs diff --git a/src/nodenorm/handlers/health.py b/src/nodenorm/handlers/health.py index 0955604..523fc7d 100644 --- a/src/nodenorm/handlers/health.py +++ b/src/nodenorm/handlers/health.py @@ -2,12 +2,11 @@ from elasticsearch import AsyncElasticsearch -from biothings.web.handlers import BaseHandler - from nodenorm.biolink import BIOLINK_MODEL_VERSION +from nodenorm.handlers.base import NodeNormalizationBaseHandler -class NodeNormHealthHandler(BaseHandler): +class NodeNormHealthHandler(NodeNormalizationBaseHandler): """ Important Endpoints * /_cat/nodes diff --git a/src/nodenorm/handlers/normalized_nodes.py b/src/nodenorm/handlers/normalized_nodes.py index 3d2a232..8da380d 100644 --- a/src/nodenorm/handlers/normalized_nodes.py +++ b/src/nodenorm/handlers/normalized_nodes.py @@ -4,10 +4,10 @@ import time from typing import Union -from biothings.web.handlers import BaseHandler from tornado.web import HTTPError from nodenorm.biolink import toolkit +from nodenorm.handlers.base import NodeNormalizationBaseHandler from nodenorm.namespace import NodeNormalizationAPINamespace logger = logging.getLogger(__name__) @@ -25,7 +25,7 @@ class NormalizedNode: taxa: list[str] -class NormalizedNodesHandler(BaseHandler): +class NormalizedNodesHandler(NodeNormalizationBaseHandler): """ Mirror implementation to the renci implementation found at https://nodenormalization-sri.renci.org/docs diff --git a/src/nodenorm/handlers/semantic_types.py b/src/nodenorm/handlers/semantic_types.py index 05a3219..3201e1e 100644 --- a/src/nodenorm/handlers/semantic_types.py +++ b/src/nodenorm/handlers/semantic_types.py @@ -1,10 +1,10 @@ -from biothings.web.handlers import BaseHandler from tornado.web import HTTPError from nodenorm.biolink import toolkit +from nodenorm.handlers.base import NodeNormalizationBaseHandler -class SemanticTypeHandler(BaseHandler): +class SemanticTypeHandler(NodeNormalizationBaseHandler): """ Mirror implementation to the renci implementation found at https://nodenormalization-sri.renci.org/docs diff --git a/src/nodenorm/handlers/set_identifiers.py b/src/nodenorm/handlers/set_identifiers.py index 1bb160e..728ff3c 100644 --- a/src/nodenorm/handlers/set_identifiers.py +++ b/src/nodenorm/handlers/set_identifiers.py @@ -6,10 +6,9 @@ import uuid from typing import Optional -from biothings.web.handlers import BaseHandler - from tornado.web import HTTPError +from nodenorm.handlers.base import NodeNormalizationBaseHandler from nodenorm.handlers.normalized_nodes import get_normalized_nodes from nodenorm.namespace import NodeNormalizationAPINamespace @@ -24,7 +23,7 @@ class SetIDResponse: setid: Optional[str] = None -class SetIdentifierHandler(BaseHandler): +class SetIdentifierHandler(NodeNormalizationBaseHandler): """ Mirror implementation to the renci implementation found at https://nodenormalization-sri.renci.org/docs diff --git a/src/nodenorm/handlers/version.py b/src/nodenorm/handlers/version.py index 734606f..5d258ac 100644 --- a/src/nodenorm/handlers/version.py +++ b/src/nodenorm/handlers/version.py @@ -1,9 +1,8 @@ -from biothings.web.handlers import BaseHandler - +from nodenorm.handlers.base import NodeNormalizationBaseHandler from nodenorm.version import get_version -class VersionHandler(BaseHandler): +class VersionHandler(NodeNormalizationBaseHandler): name = "version" async def get(self, *args, **kwargs): diff --git a/src/nodenorm/namespace.py b/src/nodenorm/namespace.py index 094dfa6..55bc6f2 100644 --- a/src/nodenorm/namespace.py +++ b/src/nodenorm/namespace.py @@ -140,8 +140,9 @@ def load_configuration(self, option_configuration: tornado.options.OptionParser) configuration.update(default_configuration) - if option_configuration.conf is not None: - optional_configuration = pathlib.Path(option_configuration.conf).absolute().resolve() + optional_configuration_file = getattr(option_configuration, "conf", None) + if optional_configuration_file is not None: + optional_configuration = pathlib.Path(optional_configuration_file).absolute().resolve() if optional_configuration.exists(): with open(optional_configuration, "r", encoding="utf-8") as handle: configuration.update(json.load(handle)) @@ -155,11 +156,13 @@ def load_configuration(self, option_configuration: tornado.options.OptionParser) configuration_namespace = types.SimpleNamespace(**configuration) # override options - if option_configuration.host is not None: - configuration_namespace.webserver["HOST"] = option_configuration.host + option_host = getattr(option_configuration, "host", None) + if option_host is not None: + configuration_namespace.webserver["HOST"] = option_host - if option_configuration.port is not None: - configuration_namespace.webserver["PORT"] = option_configuration.port + option_port = getattr(option_configuration, "port", None) + if option_port is not None: + configuration_namespace.webserver["PORT"] = option_port return configuration_namespace diff --git a/tests/test_cors.py b/tests/test_cors.py new file mode 100644 index 0000000..6a65e13 --- /dev/null +++ b/tests/test_cors.py @@ -0,0 +1,64 @@ +import importlib.util +from pathlib import Path + +import tornado.web +from tornado.testing import AsyncHTTPTestCase + + +ORIGIN = "https://translatorsri.github.io" +BASE_HANDLER_PATH = Path(__file__).parents[1] / "src" / "nodenorm" / "handlers" / "base.py" + + +def load_base_handler(): + spec = importlib.util.spec_from_file_location("_nodenorm_base_handler_under_test", BASE_HANDLER_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.NodeNormalizationBaseHandler + + +NodeNormalizationBaseHandler = load_base_handler() + + +class PreflightHandler(NodeNormalizationBaseHandler): + async def get(self): + self.finish({"ok": True}) + + +def assert_cors_headers(headers, allowed_headers="*"): + assert headers["Access-Control-Allow-Origin"] == ORIGIN + assert headers["Access-Control-Allow-Credentials"] == "true" + assert headers["Access-Control-Allow-Methods"] == "GET, POST, HEAD, OPTIONS" + assert headers["Access-Control-Allow-Headers"] == allowed_headers + assert headers["Access-Control-Max-Age"] == "600" + assert headers["Vary"] == "Origin" + + +class TestCorsHeaders(AsyncHTTPTestCase): + def get_app(self) -> tornado.web.Application: + return tornado.web.Application( + [ + (r"/version", PreflightHandler), + (r"/get_normalized_nodes", PreflightHandler), + ] + ) + + def test_get_response_includes_cors_headers_for_browser_origin(self): + response = self.fetch("/version", headers={"Origin": ORIGIN}) + + assert response.code == 200 + assert_cors_headers(response.headers) + + def test_preflight_returns_cors_headers(self): + response = self.fetch( + "/get_normalized_nodes", + method="OPTIONS", + headers={ + "Origin": ORIGIN, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + ) + + assert response.code == 200 + assert response.body == b"" + assert_cors_headers(response.headers, "content-type") From 6e9e52a0f734c75a000626c84d3689a5b3fe70fd Mon Sep 17 00:00:00 2001 From: shuchenliu Date: Sat, 16 May 2026 14:38:55 -0700 Subject: [PATCH 2/5] Address Copilot CORS review feedback --- src/nodenorm/handlers/__init__.py | 12 ++++++------ src/nodenorm/handlers/base.py | 7 ++++--- tests/test_cors.py | 20 +++----------------- 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/src/nodenorm/handlers/__init__.py b/src/nodenorm/handlers/__init__.py index 308dd6b..d30a44c 100644 --- a/src/nodenorm/handlers/__init__.py +++ b/src/nodenorm/handlers/__init__.py @@ -4,16 +4,16 @@ import tornado.web import nodenorm -from nodenorm.handlers.conflations import ValidConflationsHandler -from nodenorm.handlers.health import NodeNormHealthHandler -from nodenorm.handlers.normalized_nodes import NormalizedNodesHandler -from nodenorm.handlers.semantic_types import SemanticTypeHandler -from nodenorm.handlers.set_identifiers import SetIdentifierHandler -from nodenorm.handlers.version import VersionHandler def build_handlers() -> dict[str, tuple[str, Callable]]: """Generate our handler mapping for the nodenorm API.""" + from nodenorm.handlers.conflations import ValidConflationsHandler + from nodenorm.handlers.health import NodeNormHealthHandler + from nodenorm.handlers.normalized_nodes import NormalizedNodesHandler + from nodenorm.handlers.semantic_types import SemanticTypeHandler + from nodenorm.handlers.set_identifiers import SetIdentifierHandler + from nodenorm.handlers.version import VersionHandler handler_collection = [ (r"/get_allowed_conflations?", ValidConflationsHandler), diff --git a/src/nodenorm/handlers/base.py b/src/nodenorm/handlers/base.py index 1d2c254..5f393e5 100644 --- a/src/nodenorm/handlers/base.py +++ b/src/nodenorm/handlers/base.py @@ -6,6 +6,8 @@ class NodeNormalizationBaseHandler(BaseHandler): """Base handler that keeps the lightweight BioThings handler plus CORS.""" + cors_origin = "*" + cors_allow_credentials = "false" cors_methods = "GET, POST, HEAD, OPTIONS" cors_max_age = "600" @@ -16,12 +18,11 @@ def set_default_headers(self): requested_headers = self.request.headers.get("Access-Control-Request-Headers") - self.set_header("Access-Control-Allow-Origin", origin) - self.set_header("Access-Control-Allow-Credentials", "true") + self.set_header("Access-Control-Allow-Origin", self.cors_origin) + self.set_header("Access-Control-Allow-Credentials", self.cors_allow_credentials) self.set_header("Access-Control-Allow-Methods", self.cors_methods) self.set_header("Access-Control-Allow-Headers", requested_headers or "*") self.set_header("Access-Control-Max-Age", self.cors_max_age) - self.set_header("Vary", "Origin") def options(self, *args, **kwargs): self.finish() diff --git a/tests/test_cors.py b/tests/test_cors.py index 6a65e13..9f5535d 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -1,22 +1,9 @@ -import importlib.util -from pathlib import Path - import tornado.web from tornado.testing import AsyncHTTPTestCase +from nodenorm.handlers.base import NodeNormalizationBaseHandler ORIGIN = "https://translatorsri.github.io" -BASE_HANDLER_PATH = Path(__file__).parents[1] / "src" / "nodenorm" / "handlers" / "base.py" - - -def load_base_handler(): - spec = importlib.util.spec_from_file_location("_nodenorm_base_handler_under_test", BASE_HANDLER_PATH) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.NodeNormalizationBaseHandler - - -NodeNormalizationBaseHandler = load_base_handler() class PreflightHandler(NodeNormalizationBaseHandler): @@ -25,12 +12,11 @@ async def get(self): def assert_cors_headers(headers, allowed_headers="*"): - assert headers["Access-Control-Allow-Origin"] == ORIGIN - assert headers["Access-Control-Allow-Credentials"] == "true" + assert headers["Access-Control-Allow-Origin"] == "*" + assert headers["Access-Control-Allow-Credentials"] == "false" assert headers["Access-Control-Allow-Methods"] == "GET, POST, HEAD, OPTIONS" assert headers["Access-Control-Allow-Headers"] == allowed_headers assert headers["Access-Control-Max-Age"] == "600" - assert headers["Vary"] == "Origin" class TestCorsHeaders(AsyncHTTPTestCase): From 49578408de36aefbc1fe6ac7c01c637c1c03f5f8 Mon Sep 17 00:00:00 2001 From: shuchenliu Date: Sat, 16 May 2026 22:47:06 -0700 Subject: [PATCH 3/5] Omit non-credentialed CORS header --- src/nodenorm/handlers/base.py | 2 -- tests/test_cors.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/nodenorm/handlers/base.py b/src/nodenorm/handlers/base.py index 5f393e5..88f80f9 100644 --- a/src/nodenorm/handlers/base.py +++ b/src/nodenorm/handlers/base.py @@ -7,7 +7,6 @@ class NodeNormalizationBaseHandler(BaseHandler): """Base handler that keeps the lightweight BioThings handler plus CORS.""" cors_origin = "*" - cors_allow_credentials = "false" cors_methods = "GET, POST, HEAD, OPTIONS" cors_max_age = "600" @@ -19,7 +18,6 @@ def set_default_headers(self): requested_headers = self.request.headers.get("Access-Control-Request-Headers") self.set_header("Access-Control-Allow-Origin", self.cors_origin) - self.set_header("Access-Control-Allow-Credentials", self.cors_allow_credentials) self.set_header("Access-Control-Allow-Methods", self.cors_methods) self.set_header("Access-Control-Allow-Headers", requested_headers or "*") self.set_header("Access-Control-Max-Age", self.cors_max_age) diff --git a/tests/test_cors.py b/tests/test_cors.py index 9f5535d..96ad8b6 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -13,7 +13,7 @@ async def get(self): def assert_cors_headers(headers, allowed_headers="*"): assert headers["Access-Control-Allow-Origin"] == "*" - assert headers["Access-Control-Allow-Credentials"] == "false" + assert "Access-Control-Allow-Credentials" not in headers assert headers["Access-Control-Allow-Methods"] == "GET, POST, HEAD, OPTIONS" assert headers["Access-Control-Allow-Headers"] == allowed_headers assert headers["Access-Control-Max-Age"] == "600" From 0e2cf9d712a1c937e1b9f7eec4368aa55532da54 Mon Sep 17 00:00:00 2001 From: shuchenliu Date: Sat, 16 May 2026 22:54:26 -0700 Subject: [PATCH 4/5] Keep unused curie prefix handler out of CORS fix --- src/nodenorm/handlers/curie_prefix.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nodenorm/handlers/curie_prefix.py b/src/nodenorm/handlers/curie_prefix.py index e722d4e..e4a4dc6 100644 --- a/src/nodenorm/handlers/curie_prefix.py +++ b/src/nodenorm/handlers/curie_prefix.py @@ -1,10 +1,10 @@ +from biothings.web.handlers import BaseHandler from tornado.web import HTTPError from nodenorm.biolink import toolkit -from nodenorm.handlers.base import NodeNormalizationBaseHandler -class SemanticTypeHandler(NodeNormalizationBaseHandler): +class SemanticTypeHandler(BaseHandler): """ Mirror implementation to the renci implementation found at https://nodenormalization-sri.renci.org/docs From 3552adc8170d71c523b64ffb842e14f31c86c2a6 Mon Sep 17 00:00:00 2001 From: shuchenliu Date: Sat, 16 May 2026 22:59:20 -0700 Subject: [PATCH 5/5] Preserve BaseHandler default headers --- src/nodenorm/handlers/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/nodenorm/handlers/base.py b/src/nodenorm/handlers/base.py index 88f80f9..e0a402b 100644 --- a/src/nodenorm/handlers/base.py +++ b/src/nodenorm/handlers/base.py @@ -11,6 +11,8 @@ class NodeNormalizationBaseHandler(BaseHandler): cors_max_age = "600" def set_default_headers(self): + super().set_default_headers() + origin = self.request.headers.get("Origin") if origin is None: return