Repository: jupyterhub/jupyterhub

Issue:
API handlers are returning text/html responses
I've recently upgrade to v0.9.3 from v0.8.0. When I try to make a request to any of the API endpoints (`/hub/api/info` - for instance) I'm getting back the header Content-Type set to text/html instead of application/json as it used to be prior the upgrade.

Should I use some configuration parameter, maybe related to tornado?

Thanks!


Discussion:
Happens for me as well with `curl -v https://hub.mybinder.org/hub/api`. Not sure if something changed that by default you get `text/html` and have to send a header with your request to ask for JSON. Otherwise this seems like a regression.
@betatim We tried to overcome this issue by forking the repo and setting the header on every API handler - though it'd be cleaner to use some kind of interceptor method.

We'll keep watching for new updates.
Do you want to make a PR to upstream your changes?
Yes, I'll make it ASAP.
The issue is that a `.get_content_type` method was [removed by accident](https://github.com/jupyterhub/jupyterhub/commit/a3ed3874559cfa551a2f0a1f915496210ed22f4f#diff-25c1b30b13352428653b89995c9ab459L21-L23) from `jupyterhub/apihandlers/base.py`. Adding back:

```python
def get_content_type(self):
    return 'application/json'
```

to `jupyterhub.apihandlers.base.APIHandler` should fix this. @cristofercri are you planning to make that PR? If so, I will be happy to merge it and release 0.9.4 with the fix.

@cristofercri do you have time for the PR in the next couple of days? If not, I'm happy to make it. Thanks for the report!
@minrk since your fix seems simpler than mine, please go for it and make the PR. Thanks for your work! 

Resolving patch:
diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py
--- a/jupyterhub/apihandlers/base.py
+++ b/jupyterhub/apihandlers/base.py
@@ -30,6 +30,9 @@ class APIHandler(BaseHandler):
     def content_security_policy(self):
         return '; '.join([super().content_security_policy, "default-src 'none'"])
 
+    def get_content_type(self):
+        return 'application/json'
+
     def check_referer(self):
         """Check Origin for cross-site API requests.
 
@@ -265,3 +268,13 @@ def _check_group_model(self, model):
 
     def options(self, *args, **kwargs):
         self.finish()
+
+
+class API404(APIHandler):
+    """404 for API requests
+
+    Ensures JSON 404 errors for malformed URLs
+    """
+    async def prepare(self):
+        await super().prepare()
+        raise web.HTTPError(404)
diff --git a/jupyterhub/app.py b/jupyterhub/app.py
--- a/jupyterhub/app.py
+++ b/jupyterhub/app.py
@@ -973,6 +973,8 @@ def init_handlers(self):
         h.extend(self.extra_handlers)
 
         h.append((r'/logo', LogoHandler, {'path': self.logo_file}))
+        h.append((r'/api/(.*)', apihandlers.base.API404))
+
         self.handlers = self.add_url_prefix(self.hub_prefix, h)
         # some extra handlers, outside hub_prefix
         self.handlers.extend([


Test patch:
diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py
--- a/jupyterhub/tests/test_api.py
+++ b/jupyterhub/tests/test_api.py
@@ -100,6 +100,8 @@ def api_request(app, *api_path, **kwargs):
     assert "frame-ancestors 'self'" in resp.headers['Content-Security-Policy']
     assert ujoin(app.hub.base_url, "security/csp-report") in resp.headers['Content-Security-Policy']
     assert 'http' not in resp.headers['Content-Security-Policy']
+    if not kwargs.get('stream', False) and resp.content:
+        assert resp.headers.get('content-type') == 'application/json'
     return resp
 
 
@@ -746,6 +748,8 @@ def test_progress(request, app, no_patience, slow_spawn):
     r = yield api_request(app, 'users', name, 'server/progress', stream=True)
     r.raise_for_status()
     request.addfinalizer(r.close)
+    assert r.headers['content-type'] == 'text/event-stream'
+
     ex = async_requests.executor
     line_iter = iter(r.iter_lines(decode_unicode=True))
     evt = yield ex.submit(next_event, line_iter)
@@ -807,6 +811,7 @@ def test_progress_ready(request, app):
     r = yield api_request(app, 'users', name, 'server/progress', stream=True)
     r.raise_for_status()
     request.addfinalizer(r.close)
+    assert r.headers['content-type'] == 'text/event-stream'
     ex = async_requests.executor
     line_iter = iter(r.iter_lines(decode_unicode=True))
     evt = yield ex.submit(next_event, line_iter)
@@ -826,6 +831,7 @@ def test_progress_bad(request, app, no_patience, bad_spawn):
     r = yield api_request(app, 'users', name, 'server/progress', stream=True)
     r.raise_for_status()
     request.addfinalizer(r.close)
+    assert r.headers['content-type'] == 'text/event-stream'
     ex = async_requests.executor
     line_iter = iter(r.iter_lines(decode_unicode=True))
     evt = yield ex.submit(next_event, line_iter)
@@ -847,6 +853,7 @@ def test_progress_bad_slow(request, app, no_patience, slow_bad_spawn):
     r = yield api_request(app, 'users', name, 'server/progress', stream=True)
     r.raise_for_status()
     request.addfinalizer(r.close)
+    assert r.headers['content-type'] == 'text/event-stream'
     ex = async_requests.executor
     line_iter = iter(r.iter_lines(decode_unicode=True))
     evt = yield ex.submit(next_event, line_iter)