Repository: blaze/blaze

Issue:
blaze.server does not allow CORS
`blaze.server` sticks with same origin policy so it does not enable CORS requests.. This is an issue for users using bokeh `BlazeDataSources` to map to a blaze server and update data directly on the browser without `bokeh.server` because this `blaze.server` (well, Flask default) configuration will raise an error. Something like:

```
  XMLHttpRequest cannot load http://domain:9000/compute.json. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://domain:5006' is therefore not allowed access.
```

.

This issue can be fixed by setting (injecting) the CORS policies on `blaze.server`. For instance:

``` Python
        server = blaze.Server({'dat': gauss})

        def add_crossdomain_headers(response):
            response.headers.add('Access-Control-Allow-Origin', '*')
            response.headers.add('Access-Control-Allow-Methods', 'GET,POST')
            response.headers.add('Access-Control-Allow-Headers', "Origin, X-Requested-With, Content-Type, Accept")

            return response

        server.app.after_request(add_crossdomain_headers)
        server.run(host="127.0.0.1", port=9000)

```

But a probably better solution would be to have some kind of configuration on `blaze.server` itself so users don't have to bother too much trying to find the solution. 

NOTE: Flask does expose a `crossdomain` decorator that can be used to decorate views (and that may be a better solution) but I didn't figure out a quick way of using it within `blaze.server` before the views are declared (I may be overlooking something trivial, I honestly didn't have much time to dig in details).



Discussion:
I think the one we use in bokeh is a good

https://github.com/bokeh/bokeh/blob/master/bokeh/server/crossdomain.py

It's been modified from the original (http://flask.pocoo.org/snippets/56/)

To set access control allow headers to whatever was sent (if you set headers to None), so that differnet ajax pages which may have different default headers set don't break

For something like the blaze server - I think allowing crossdomain should be the default - since the blaze server doesn't have any web pages, any ajax request hitting the blaze server is always going to come from another domain

@fpliger @hhuuggoo it'd be nice to avoid duplication here. would it be reasonable to use this library http://flask-cors.readthedocs.org/en/latest/ to support crossdomain requests?

even simpler : https://github.com/iurisilvio/flask-crossdomain

I'm sure @tswicegood input would be good here, I think this line is important 

https://github.com/bokeh/bokeh/blob/master/bokeh/server/crossdomain.py#L48

and it's something we added, and is not in https://github.com/iurisilvio/flask-crossdomain

The reason I think it's important, is that it's nice to allow blaze server creators to not have to specify which headers they need to accept beforehand, and instead just allow arbitrary headers 

I think if you want to enable CORS support on all requests as either a boolean, I would go with Flask-CORS and their [basic approach](http://flask-cors.readthedocs.org/en/latest/#simple-usage).  It does make sense for that to be the out-of-the-box behavior.

> For something like the blaze server - I think allowing crossdomain should be the default - since the blaze server doesn't have any web pages, any ajax request hitting the blaze server is always going to come from another domain

Fully agree

> I think if you want to enable CORS support on all requests as either a boolean, I would go with Flask-CORS and their basic approach. It does make sense for that to be the out-of-the-box behavior.

ditto


Resolving patch:
diff --git a/blaze/server/server.py b/blaze/server/server.py
--- a/blaze/server/server.py
+++ b/blaze/server/server.py
@@ -1,12 +1,25 @@
 from __future__ import absolute_import, division, print_function
 
 import socket
+import functools
 
 import flask
 from flask import Blueprint, Flask, request
 
+try:
+    from bokeh.server.crossdomain import crossdomain
+except ImportError:
+    def crossdomain(*args, **kwargs):
+        def wrapper(f):
+            @functools.wraps(f)
+            def wrapped(*a, **k):
+                return f(*a, **k)
+            return wrapped
+        return wrapper
+
 from toolz import assoc
 
+from datashape import Mono, discover
 from datashape.predicates import iscollection, isscalar
 from odo import odo
 
@@ -19,8 +32,6 @@
 from ..interactive import InteractiveSymbol, coerce_scalar
 from ..expr import Expr, symbol
 
-from datashape import Mono, discover
-
 
 __all__ = 'Server', 'to_tree', 'from_tree'
 
@@ -130,8 +141,9 @@ def run(self, *args, **kwargs):
                 self.run(*args, **assoc(kwargs, 'port', port + 1))
 
 
-@api.route('/datashape')
-def dataset():
+@api.route('/datashape', methods=['GET'])
+@crossdomain(origin='*', methods=['GET'])
+def shape():
     return str(discover(_get_data()))
 
 
@@ -144,8 +156,8 @@ def to_tree(expr, names=None):
 
     Parameters
     ----------
-
-    expr: Blaze Expression
+    expr : Expr
+        A Blaze expression
 
     Examples
     --------
@@ -304,7 +316,9 @@ def from_tree(expr, namespace=None):
         return expr
 
 
-@api.route('/compute.<serial_format>', methods=['POST', 'PUT', 'GET'])
+@api.route('/compute.<serial_format>',
+           methods=['POST', 'GET', 'HEAD', 'OPTIONS'])
+@crossdomain(origin='*', methods=['POST', 'GET', 'HEAD', 'OPTIONS'])
 def compserver(serial_format):
     try:
         serial = _get_format(serial_format)


Test patch:
diff --git a/blaze/server/tests/test_server.py b/blaze/server/tests/test_server.py
--- a/blaze/server/tests/test_server.py
+++ b/blaze/server/tests/test_server.py
@@ -33,21 +33,27 @@
           'events': events,
               'db': db}
 
-server = Server(data, all_formats)
 
-test = server.app.test_client()
+@pytest.fixture(scope='module')
+def server():
+    s = Server(data, all_formats)
+    s.app.testing = True
+    return s
 
 
-def test_datasets():
+@pytest.yield_fixture
+def test(server):
+    with server.app.test_client() as c:
+        yield c
+
+
+def test_datasets(test):
     response = test.get('/datashape')
     assert response.data.decode('utf-8') == str(discover(data))
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
-def test_bad_responses(serial):
+@pytest.mark.parametrize('serial', all_formats)
+def test_bad_responses(test, serial):
     assert 'OK' not in test.post(
         '/compute/accounts.{name}'.format(name=serial.name),
         data=serial.dumps(500),
@@ -88,10 +94,7 @@ def test_to_tree():
     assert to_tree(expr) == expected
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
+@pytest.mark.parametrize('serial', all_formats)
 def test_to_tree_slice(serial):
     t = symbol('t', 'var * {name: string, amount: int32}')
     expr = t[:5]
@@ -122,11 +125,8 @@ def test_from_tree_is_robust_to_unnecessary_namespace():
 t = symbol('t', discover(data))
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
-def test_compute(serial):
+@pytest.mark.parametrize('serial', all_formats)
+def test_compute(test, serial):
     expr = t.accounts.amount.sum()
     query = {'expr': to_tree(expr)}
     expected = 300
@@ -142,11 +142,8 @@ def test_compute(serial):
     assert data['names'] == ['amount_sum']
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
-def test_get_datetimes(serial):
+@pytest.mark.parametrize('serial', all_formats)
+def test_get_datetimes(test, serial):
     expr = t.events
     query = {'expr': to_tree(expr)}
 
@@ -163,11 +160,8 @@ def test_get_datetimes(serial):
     assert data['names'] == events.columns.tolist()
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
-def dont_test_compute_with_namespace(serial):
+@pytest.mark.parametrize('serial', all_formats)
+def dont_test_compute_with_namespace(test, serial):
     query = {'expr': {'op': 'Field',
                       'args': ['accounts', 'name']}}
     expected = ['Alice', 'Bob']
@@ -183,19 +177,19 @@ def dont_test_compute_with_namespace(serial):
     assert data['names'] == ['name']
 
 
-@pytest.fixture
-def iris_server(request):
+@pytest.yield_fixture
+def iris_server():
     iris = CSV(example('iris.csv'))
-    return Server(iris, all_formats).app.test_client()
+    s = Server(iris, all_formats)
+    s.app.testing = True
+    with s.app.test_client() as c:
+        yield c
 
 
 iris = CSV(example('iris.csv'))
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
+@pytest.mark.parametrize('serial', all_formats)
 def test_compute_with_variable_in_namespace(iris_server, serial):
     test = iris_server
     t = symbol('t', discover(iris))
@@ -217,10 +211,7 @@ def test_compute_with_variable_in_namespace(iris_server, serial):
     assert data['names'] == ['species']
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
+@pytest.mark.parametrize('serial', all_formats)
 def test_compute_by_with_summary(iris_server, serial):
     test = iris_server
     t = symbol('t', discover(iris))
@@ -244,10 +235,7 @@ def test_compute_by_with_summary(iris_server, serial):
     assert data['names'] == ['species', 'max', 'sum']
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
+@pytest.mark.parametrize('serial', all_formats)
 def test_compute_column_wise(iris_server, serial):
     test = iris_server
     t = symbol('t', discover(iris))
@@ -269,11 +257,8 @@ def test_compute_column_wise(iris_server, serial):
     assert data['names'] == t.fields
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
-def test_multi_expression_compute(serial):
+@pytest.mark.parametrize('serial', all_formats)
+def test_multi_expression_compute(test, serial):
     s = symbol('s', discover(data))
 
     expr = join(s.accounts, s.cities)
@@ -292,11 +277,8 @@ def test_multi_expression_compute(serial):
     assert respdata['names'] == expr.fields
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
-def test_leaf_symbol(serial):
+@pytest.mark.parametrize('serial', all_formats)
+def test_leaf_symbol(test, serial):
     query = {'expr': {'op': 'Field', 'args': [':leaf', 'cities']}}
     resp = test.post(
         '/compute.{name}'.format(name=serial.name),
@@ -311,11 +293,8 @@ def test_leaf_symbol(serial):
     assert data['names'] == cities.columns.tolist()
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
-def test_sqlalchemy_result(serial):
+@pytest.mark.parametrize('serial', all_formats)
+def test_sqlalchemy_result(test, serial):
     expr = t.db.iris.head(5)
     query = {'expr': to_tree(expr)}
 
@@ -335,11 +314,8 @@ def test_server_accepts_non_nonzero_ables():
     Server(DataFrame())
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
-def test_server_can_compute_sqlalchemy_reductions(serial):
+@pytest.mark.parametrize('serial', all_formats)
+def test_server_can_compute_sqlalchemy_reductions(test, serial):
     expr = t.db.iris.petal_length.sum()
     query = {'expr': to_tree(expr)}
     response = test.post(
@@ -353,11 +329,8 @@ def test_server_can_compute_sqlalchemy_reductions(serial):
     assert respdata['names'] == ['petal_length_sum']
 
 
-@pytest.mark.parametrize(
-    'serial',
-    all_formats,
-)
-def test_serialization_endpoints(serial):
+@pytest.mark.parametrize('serial', all_formats)
+def test_serialization_endpoints(test, serial):
     expr = t.db.iris.petal_length.sum()
     query = {'expr': to_tree(expr)}
     response = test.post(
@@ -369,3 +342,37 @@ def test_serialization_endpoints(serial):
     result = respdata['data']
     assert result == odo(compute(expr, {t: data}), int)
     assert respdata['names'] == ['petal_length_sum']
+
+
+@pytest.fixture
+def has_bokeh():
+    try:
+        from bokeh.server.crossdomain import crossdomain
+    except ImportError as e:
+        pytest.skip(str(e))
+
+
+@pytest.mark.parametrize('serial', all_formats)
+def test_cors_compute(test, serial, has_bokeh):
+    expr = t.db.iris.petal_length.sum()
+    res = test.post('/compute.{name}'.format(name=serial.name),
+                    data=serial.dumps(dict(expr=to_tree(expr))))
+    assert res.status_code == 200
+    assert res.headers['Access-Control-Allow-Origin'] == '*'
+    assert 'HEAD' in res.headers['Access-Control-Allow-Methods']
+    assert 'OPTIONS' in res.headers['Access-Control-Allow-Methods']
+    assert 'GET' in res.headers['Access-Control-Allow-Methods']
+
+
+@pytest.mark.parametrize('method',
+                         ['get',
+                          pytest.mark.xfail('head', raises=AssertionError),
+                          pytest.mark.xfail('options', raises=AssertionError),
+                          pytest.mark.xfail('post', raises=AssertionError)])
+def test_cors_datashape(test, method, has_bokeh):
+    res = getattr(test, method)('/datashape')
+    assert res.status_code == 200
+    assert res.headers['Access-Control-Allow-Origin'] == '*'
+    assert 'HEAD' not in res.headers['Access-Control-Allow-Methods']
+    assert 'OPTIONS' not in res.headers['Access-Control-Allow-Methods']
+    assert 'GET' in res.headers['Access-Control-Allow-Methods']