Browse Source

More complete CORS handling, including preflight requests (hopefully)

tags/nilmdb-1.2.3
Jim Paris 11 years ago
parent
commit
c6a0e6e96f
3 changed files with 88 additions and 27 deletions
  1. +59
    -15
      nilmdb/server/server.py
  2. +0
    -6
      tests/test_client.py
  3. +29
    -6
      tests/test_nilmdb.py

+ 59
- 15
nilmdb/server/server.py View File

@@ -71,6 +71,52 @@ def exception_to_httperror(*expected):
# care of that. # care of that.
return decorator.decorator(wrapper) return decorator.decorator(wrapper)


# Custom CherryPy tools

def CORS_allow(methods):
"""This does several things:

Handles CORS preflight requests.
Adds Allow: header to all requests.
Raise 405 if request.method not in method.

It is similar to cherrypy.tools.allow, with the CORS stuff added.
"""
request = cherrypy.request.headers
response = cherrypy.response.headers

if not isinstance(methods, (tuple, list)): # pragma: no cover
methods = [ methods ]
methods = [ m.upper() for m in methods if m ]
if not methods: # pragma: no cover
methods = [ 'GET', 'HEAD' ]
elif 'GET' in methods and 'HEAD' not in methods: # pragma: no cover
methods.append('HEAD')
response['Allow'] = ', '.join(methods)

# Allow all origins
if 'Origin' in request:
response['Access-Control-Allow-Origin'] = request['Origin']

# If it's a CORS request, send response.
request_method = request.get("Access-Control-Request-Method", None)
request_headers = request.get("Access-Control-Request-Headers", None)
if (cherrypy.request.method == "OPTIONS" and
request_method and request_headers):
response['Access-Control-Allow-Headers'] = request_headers
response['Access-Control-Allow-Methods'] = ', '.join(methods)
# Try to stop further processing and return a 200 OK
cherrypy.response.status = "200 OK"
cherrypy.response.body = ""
cherrypy.request.handler = lambda: ""
return

# Reject methods that were not explicitly allowed
if cherrypy.request.method not in methods:
raise cherrypy.HTTPError(405)

cherrypy.tools.CORS_allow = cherrypy.Tool('on_start_resource', CORS_allow)

# CherryPy apps # CherryPy apps
class Root(NilmApp): class Root(NilmApp):
"""Root application for NILM database""" """Root application for NILM database"""
@@ -131,7 +177,7 @@ class Stream(NilmApp):
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@exception_to_httperror(NilmDBError, ValueError) @exception_to_httperror(NilmDBError, ValueError)
@cherrypy.tools.allow(methods = ["POST"])
@cherrypy.tools.CORS_allow(methods = ["POST"])
def create(self, path, layout): def create(self, path, layout):
"""Create a new stream in the database. Provide path """Create a new stream in the database. Provide path
and one of the nilmdb.layout.layouts keys. and one of the nilmdb.layout.layouts keys.
@@ -142,7 +188,7 @@ class Stream(NilmApp):
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@exception_to_httperror(NilmDBError) @exception_to_httperror(NilmDBError)
@cherrypy.tools.allow(methods = ["POST"])
@cherrypy.tools.CORS_allow(methods = ["POST"])
def destroy(self, path): def destroy(self, path):
"""Delete a stream and its associated data.""" """Delete a stream and its associated data."""
return self.db.stream_destroy(path) return self.db.stream_destroy(path)
@@ -175,7 +221,7 @@ class Stream(NilmApp):
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@exception_to_httperror(NilmDBError, LookupError, TypeError) @exception_to_httperror(NilmDBError, LookupError, TypeError)
@cherrypy.tools.allow(methods = ["POST"])
@cherrypy.tools.CORS_allow(methods = ["POST"])
def set_metadata(self, path, data): def set_metadata(self, path, data):
"""Set metadata for the named stream, replacing any """Set metadata for the named stream, replacing any
existing metadata. Data should be a json-encoded existing metadata. Data should be a json-encoded
@@ -187,7 +233,7 @@ class Stream(NilmApp):
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@exception_to_httperror(NilmDBError, LookupError, TypeError) @exception_to_httperror(NilmDBError, LookupError, TypeError)
@cherrypy.tools.allow(methods = ["POST"])
@cherrypy.tools.CORS_allow(methods = ["POST"])
def update_metadata(self, path, data): def update_metadata(self, path, data):
"""Update metadata for the named stream. Data """Update metadata for the named stream. Data
should be a json-encoded dictionary""" should be a json-encoded dictionary"""
@@ -197,7 +243,7 @@ class Stream(NilmApp):
# /stream/insert?path=/newton/prep # /stream/insert?path=/newton/prep
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@cherrypy.tools.allow(methods = ["PUT"])
@cherrypy.tools.CORS_allow(methods = ["PUT"])
def insert(self, path, start, end): def insert(self, path, start, end):
""" """
Insert new data into the database. Provide textual data Insert new data into the database. Provide textual data
@@ -253,7 +299,7 @@ class Stream(NilmApp):
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@exception_to_httperror(NilmDBError) @exception_to_httperror(NilmDBError)
@cherrypy.tools.allow(methods = ["POST"])
@cherrypy.tools.CORS_allow(methods = ["POST"])
def remove(self, path, start = None, end = None): def remove(self, path, start = None, end = None):
""" """
Remove data from the backend database. Removes all data in Remove data from the backend database. Removes all data in
@@ -408,16 +454,14 @@ class Server(object):
'error_page.default': self.json_error_page, 'error_page.default': self.json_error_page,
}) })


# Send a permissive Access-Control-Allow-Origin (CORS) header
# with all responses so that browsers can send cross-domain
# requests to this server.
app_config.update({ 'response.headers.Access-Control-Allow-Origin':
'*' })
# Some default headers to just help identify that things are working
app_config.update({ 'response.headers.X-Jim-Is-Awesome': 'yeah' })


# Only allow GET and HEAD by default. Individual handlers
# can override.
app_config.update({ 'tools.allow.on': True,
'tools.allow.methods': ['GET', 'HEAD'] })
# Set up Cross-Origin Resource Sharing (CORS) handler so we
# can correctly respond to browsers' CORS preflight requests.
# This also limits verbs to GET and HEAD by default.
app_config.update({ 'tools.CORS_allow.on': True,
'tools.CORS_allow.methods': ['GET', 'HEAD'] })


# Send tracebacks in error responses. They're hidden by the # Send tracebacks in error responses. They're hidden by the
# error_page function for client errors (code 400-499). # error_page function for client errors (code 400-499).


+ 0
- 6
tests/test_client.py View File

@@ -354,12 +354,6 @@ class TestClient(object):
raise AssertionError("/stream/extract is not text/plain:\n" + raise AssertionError("/stream/extract is not text/plain:\n" +
headers()) headers())


# Make sure Access-Control-Allow-Origin gets set
if "access-control-allow-origin: " not in headers():
raise AssertionError("No Access-Control-Allow-Origin (CORS) "
"header in /stream/extract response:\n" +
headers())

client.close() client.close()


def test_client_08_unicode(self): def test_client_08_unicode(self):


+ 29
- 6
tests/test_nilmdb.py View File

@@ -207,10 +207,33 @@ class TestServer(object):
"&key=foo") "&key=foo")
eq_(data, {'foo': None}) eq_(data, {'foo': None})


def test_options(self):
# OPTIONS request should be disallowed, but should return
# a Allow: heeader.
r = requests.options("http://127.0.0.1:32180/stream/list")
def test_cors_headers(self):
# Test that CORS headers are being set correctly

# Normal GET should send simple response
url = "http://127.0.0.1:32180/stream/list"
r = requests.get(url, headers = { "Origin": "http://google.com/" })
eq_(r.status_code, 200)
if "access-control-allow-origin" not in r.headers:
raise AssertionError("No Access-Control-Allow-Origin (CORS) "
"header in response:\n", r.headers)
eq_(r.headers["access-control-allow-origin"], "http://google.com/")

# OPTIONS without CORS preflight headers should result in 405
r = requests.options(url, headers = {
"Origin": "http://google.com/",
})
eq_(r.status_code, 405) eq_(r.status_code, 405)
eq_(r.headers["allow"], "GET, HEAD")
in_("405 Method Not Allowed", r.text)

# OPTIONS with preflight headers should give preflight response
r = requests.options(url, headers = {
"Origin": "http://google.com/",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "X-Custom",
})
eq_(r.status_code, 200)
if "access-control-allow-origin" not in r.headers:
raise AssertionError("No Access-Control-Allow-Origin (CORS) "
"header in response:\n", r.headers)
eq_(r.headers["access-control-allow-methods"], "GET, HEAD")
eq_(r.headers["access-control-allow-headers"], "X-Custom")

Loading…
Cancel
Save