Skip to content

Commit fdef1fc

Browse files
authored
Fix CORS preflight handling (#17)
* Fix CORS preflight handling * Address Copilot CORS review feedback * Omit non-credentialed CORS header * Keep unused curie prefix handler out of CORS fix * Preserve BaseHandler default headers
1 parent ece211e commit fdef1fc

10 files changed

Lines changed: 105 additions & 28 deletions

File tree

src/nodenorm/handlers/__init__.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@
44
import tornado.web
55

66
import nodenorm
7-
from nodenorm.handlers.conflations import ValidConflationsHandler
8-
from nodenorm.handlers.health import NodeNormHealthHandler
9-
from nodenorm.handlers.normalized_nodes import NormalizedNodesHandler
10-
from nodenorm.handlers.semantic_types import SemanticTypeHandler
11-
from nodenorm.handlers.set_identifiers import SetIdentifierHandler
12-
from nodenorm.handlers.version import VersionHandler
137

148

159
def build_handlers() -> dict[str, tuple[str, Callable]]:
1610
"""Generate our handler mapping for the nodenorm API."""
11+
from nodenorm.handlers.conflations import ValidConflationsHandler
12+
from nodenorm.handlers.health import NodeNormHealthHandler
13+
from nodenorm.handlers.normalized_nodes import NormalizedNodesHandler
14+
from nodenorm.handlers.semantic_types import SemanticTypeHandler
15+
from nodenorm.handlers.set_identifiers import SetIdentifierHandler
16+
from nodenorm.handlers.version import VersionHandler
1717

1818
handler_collection = [
1919
(r"/get_allowed_conflations?", ValidConflationsHandler),

src/nodenorm/handlers/base.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Shared handler behavior for NodeNormalization API endpoints."""
2+
3+
from biothings.web.handlers import BaseHandler
4+
5+
6+
class NodeNormalizationBaseHandler(BaseHandler):
7+
"""Base handler that keeps the lightweight BioThings handler plus CORS."""
8+
9+
cors_origin = "*"
10+
cors_methods = "GET, POST, HEAD, OPTIONS"
11+
cors_max_age = "600"
12+
13+
def set_default_headers(self):
14+
super().set_default_headers()
15+
16+
origin = self.request.headers.get("Origin")
17+
if origin is None:
18+
return
19+
20+
requested_headers = self.request.headers.get("Access-Control-Request-Headers")
21+
22+
self.set_header("Access-Control-Allow-Origin", self.cors_origin)
23+
self.set_header("Access-Control-Allow-Methods", self.cors_methods)
24+
self.set_header("Access-Control-Allow-Headers", requested_headers or "*")
25+
self.set_header("Access-Control-Max-Age", self.cors_max_age)
26+
27+
def options(self, *args, **kwargs):
28+
self.finish()

src/nodenorm/handlers/conflations.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import logging
22

3-
from biothings.web.handlers import BaseHandler
4-
3+
from nodenorm.handlers.base import NodeNormalizationBaseHandler
54

65
logger = logging.getLogger(__name__)
76

87

9-
class ValidConflationsHandler(BaseHandler):
8+
class ValidConflationsHandler(NodeNormalizationBaseHandler):
109
name = "allowed-conflations"
1110

1211
async def get(self):

src/nodenorm/handlers/health.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
from elasticsearch import AsyncElasticsearch
44

5-
from biothings.web.handlers import BaseHandler
6-
75
from nodenorm.biolink import BIOLINK_MODEL_VERSION
6+
from nodenorm.handlers.base import NodeNormalizationBaseHandler
87

98

10-
class NodeNormHealthHandler(BaseHandler):
9+
class NodeNormHealthHandler(NodeNormalizationBaseHandler):
1110
"""
1211
Important Endpoints
1312
* /_cat/nodes

src/nodenorm/handlers/normalized_nodes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
import time
55
from typing import Union
66

7-
from biothings.web.handlers import BaseHandler
87
from tornado.web import HTTPError
98

109
from nodenorm.biolink import toolkit
10+
from nodenorm.handlers.base import NodeNormalizationBaseHandler
1111
from nodenorm.namespace import NodeNormalizationAPINamespace
1212

1313
logger = logging.getLogger(__name__)
@@ -25,7 +25,7 @@ class NormalizedNode:
2525
taxa: list[str]
2626

2727

28-
class NormalizedNodesHandler(BaseHandler):
28+
class NormalizedNodesHandler(NodeNormalizationBaseHandler):
2929
"""
3030
Mirror implementation to the renci implementation found at
3131
https://nodenormalization-sri.renci.org/docs

src/nodenorm/handlers/semantic_types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
from biothings.web.handlers import BaseHandler
21
from tornado.web import HTTPError
32

43
from nodenorm.biolink import toolkit
4+
from nodenorm.handlers.base import NodeNormalizationBaseHandler
55

66

7-
class SemanticTypeHandler(BaseHandler):
7+
class SemanticTypeHandler(NodeNormalizationBaseHandler):
88
"""
99
Mirror implementation to the renci implementation found at
1010
https://nodenormalization-sri.renci.org/docs

src/nodenorm/handlers/set_identifiers.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
import uuid
77
from typing import Optional
88

9-
from biothings.web.handlers import BaseHandler
10-
119
from tornado.web import HTTPError
1210

11+
from nodenorm.handlers.base import NodeNormalizationBaseHandler
1312
from nodenorm.handlers.normalized_nodes import get_normalized_nodes
1413
from nodenorm.namespace import NodeNormalizationAPINamespace
1514

@@ -24,7 +23,7 @@ class SetIDResponse:
2423
setid: Optional[str] = None
2524

2625

27-
class SetIdentifierHandler(BaseHandler):
26+
class SetIdentifierHandler(NodeNormalizationBaseHandler):
2827
"""
2928
Mirror implementation to the renci implementation found at
3029
https://nodenormalization-sri.renci.org/docs

src/nodenorm/handlers/version.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
from biothings.web.handlers import BaseHandler
2-
1+
from nodenorm.handlers.base import NodeNormalizationBaseHandler
32
from nodenorm.version import get_version
43

54

6-
class VersionHandler(BaseHandler):
5+
class VersionHandler(NodeNormalizationBaseHandler):
76
name = "version"
87

98
async def get(self, *args, **kwargs):

src/nodenorm/namespace.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,9 @@ def load_configuration(self, option_configuration: tornado.options.OptionParser)
140140

141141
configuration.update(default_configuration)
142142

143-
if option_configuration.conf is not None:
144-
optional_configuration = pathlib.Path(option_configuration.conf).absolute().resolve()
143+
optional_configuration_file = getattr(option_configuration, "conf", None)
144+
if optional_configuration_file is not None:
145+
optional_configuration = pathlib.Path(optional_configuration_file).absolute().resolve()
145146
if optional_configuration.exists():
146147
with open(optional_configuration, "r", encoding="utf-8") as handle:
147148
configuration.update(json.load(handle))
@@ -155,11 +156,13 @@ def load_configuration(self, option_configuration: tornado.options.OptionParser)
155156
configuration_namespace = types.SimpleNamespace(**configuration)
156157

157158
# override options
158-
if option_configuration.host is not None:
159-
configuration_namespace.webserver["HOST"] = option_configuration.host
159+
option_host = getattr(option_configuration, "host", None)
160+
if option_host is not None:
161+
configuration_namespace.webserver["HOST"] = option_host
160162

161-
if option_configuration.port is not None:
162-
configuration_namespace.webserver["PORT"] = option_configuration.port
163+
option_port = getattr(option_configuration, "port", None)
164+
if option_port is not None:
165+
configuration_namespace.webserver["PORT"] = option_port
163166

164167
return configuration_namespace
165168

tests/test_cors.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import tornado.web
2+
from tornado.testing import AsyncHTTPTestCase
3+
4+
from nodenorm.handlers.base import NodeNormalizationBaseHandler
5+
6+
ORIGIN = "https://translatorsri.github.io"
7+
8+
9+
class PreflightHandler(NodeNormalizationBaseHandler):
10+
async def get(self):
11+
self.finish({"ok": True})
12+
13+
14+
def assert_cors_headers(headers, allowed_headers="*"):
15+
assert headers["Access-Control-Allow-Origin"] == "*"
16+
assert "Access-Control-Allow-Credentials" not in headers
17+
assert headers["Access-Control-Allow-Methods"] == "GET, POST, HEAD, OPTIONS"
18+
assert headers["Access-Control-Allow-Headers"] == allowed_headers
19+
assert headers["Access-Control-Max-Age"] == "600"
20+
21+
22+
class TestCorsHeaders(AsyncHTTPTestCase):
23+
def get_app(self) -> tornado.web.Application:
24+
return tornado.web.Application(
25+
[
26+
(r"/version", PreflightHandler),
27+
(r"/get_normalized_nodes", PreflightHandler),
28+
]
29+
)
30+
31+
def test_get_response_includes_cors_headers_for_browser_origin(self):
32+
response = self.fetch("/version", headers={"Origin": ORIGIN})
33+
34+
assert response.code == 200
35+
assert_cors_headers(response.headers)
36+
37+
def test_preflight_returns_cors_headers(self):
38+
response = self.fetch(
39+
"/get_normalized_nodes",
40+
method="OPTIONS",
41+
headers={
42+
"Origin": ORIGIN,
43+
"Access-Control-Request-Method": "POST",
44+
"Access-Control-Request-Headers": "content-type",
45+
},
46+
)
47+
48+
assert response.code == 200
49+
assert response.body == b""
50+
assert_cors_headers(response.headers, "content-type")

0 commit comments

Comments
 (0)