mirror of
https://github.com/spl0k/supysonic.git
synced 2024-12-22 17:06:17 +00:00
Set mimetype when creating the response, don't try to fix it afterwards
Ref #76
This commit is contained in:
parent
8275966db0
commit
b33e8ae6d1
@ -18,16 +18,20 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
API_VERSION = '1.8.0'
|
||||||
|
|
||||||
import binascii
|
import binascii
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from flask import request, json, current_app as app
|
from flask import request
|
||||||
|
from flask import current_app as app
|
||||||
from pony.orm import db_session, ObjectNotFound
|
from pony.orm import db_session, ObjectNotFound
|
||||||
from xml.dom import minidom
|
|
||||||
from xml.etree import ElementTree
|
|
||||||
|
|
||||||
from ..managers.user import UserManager
|
from ..managers.user import UserManager
|
||||||
from ..py23 import dict, strtype
|
from ..py23 import dict
|
||||||
|
|
||||||
|
from .formatters import make_json_response, make_jsonp_response, make_xml_response
|
||||||
|
from .formatters import make_error_response_func
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def set_formatter():
|
def set_formatter():
|
||||||
@ -35,23 +39,15 @@ def set_formatter():
|
|||||||
return
|
return
|
||||||
|
|
||||||
"""Return a function to create the response."""
|
"""Return a function to create the response."""
|
||||||
(f, callback) = map(request.values.get, ['f', 'callback'])
|
f, callback = map(request.values.get, ['f', 'callback'])
|
||||||
if f == 'jsonp':
|
if f == 'jsonp':
|
||||||
# Some clients (MiniSub, Perisonic) set f to jsonp without callback for streamed data
|
request.formatter = lambda x, **kwargs: make_jsonp_response(x, callback, kwargs)
|
||||||
if not callback and request.endpoint not in [ 'stream_media', 'cover_art' ]:
|
elif f == 'json':
|
||||||
return ResponseHelper.responsize_json(dict(
|
request.formatter = make_json_response
|
||||||
error = dict(
|
|
||||||
code = 10,
|
|
||||||
message = 'Missing callback'
|
|
||||||
)
|
|
||||||
), error = True), 400
|
|
||||||
request.formatter = lambda x, **kwargs: ResponseHelper.responsize_jsonp(x, callback, kwargs)
|
|
||||||
elif f == "json":
|
|
||||||
request.formatter = ResponseHelper.responsize_json
|
|
||||||
else:
|
else:
|
||||||
request.formatter = ResponseHelper.responsize_xml
|
request.formatter = make_xml_response
|
||||||
|
|
||||||
request.error_formatter = lambda code, msg: request.formatter(dict(error = dict(code = code, message = msg)), error = True)
|
request.error_formatter = make_error_response_func(request.formatter)
|
||||||
|
|
||||||
def decode_password(password):
|
def decode_password(password):
|
||||||
if not password.startswith('enc:'):
|
if not password.startswith('enc:'):
|
||||||
@ -110,18 +106,7 @@ def set_headers(response):
|
|||||||
if not request.path.startswith('/rest/'):
|
if not request.path.startswith('/rest/'):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
if response.mimetype.startswith('text'):
|
|
||||||
f = request.values.get('f')
|
|
||||||
# TODO set the mimetype when creating the response, here we could lose some info
|
|
||||||
if f == 'json':
|
|
||||||
response.headers['Content-Type'] = 'application/json'
|
|
||||||
elif f == 'jsonp':
|
|
||||||
response.headers['Content-Type'] = 'application/javascript'
|
|
||||||
else:
|
|
||||||
response.headers['Content-Type'] = 'text/xml'
|
|
||||||
|
|
||||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
@ -131,98 +116,6 @@ def not_found(error):
|
|||||||
|
|
||||||
return request.error_formatter(0, 'Not implemented'), 501
|
return request.error_formatter(0, 'Not implemented'), 501
|
||||||
|
|
||||||
class ResponseHelper:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def remove_empty_lists(d):
|
|
||||||
if not isinstance(d, dict):
|
|
||||||
raise TypeError('Expecting a dict got ' + type(d).__name__)
|
|
||||||
|
|
||||||
keys_to_remove = []
|
|
||||||
for key, value in d.items():
|
|
||||||
if isinstance(value, dict):
|
|
||||||
d[key] = ResponseHelper.remove_empty_lists(value)
|
|
||||||
elif isinstance(value, list):
|
|
||||||
if len(value) == 0:
|
|
||||||
keys_to_remove.append(key)
|
|
||||||
else:
|
|
||||||
d[key] = [ ResponseHelper.remove_empty_lists(item) if isinstance(item, dict) else item for item in value ]
|
|
||||||
|
|
||||||
for key in keys_to_remove:
|
|
||||||
del d[key]
|
|
||||||
|
|
||||||
return d
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def responsize_json(ret, error = False, version = "1.8.0"):
|
|
||||||
ret = ResponseHelper.remove_empty_lists(ret)
|
|
||||||
|
|
||||||
# add headers to response
|
|
||||||
ret.update(
|
|
||||||
status = 'failed' if error else 'ok',
|
|
||||||
version = version
|
|
||||||
)
|
|
||||||
return json.dumps({ 'subsonic-response': ret }, indent = True)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def responsize_jsonp(ret, callback, error = False, version = "1.8.0"):
|
|
||||||
return '{}({})'.format(callback, ResponseHelper.responsize_json(ret, error, version))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def responsize_xml(ret, error = False, version = "1.8.0"):
|
|
||||||
"""Return an xml response from json and replace unsupported characters."""
|
|
||||||
ret.update(
|
|
||||||
status = 'failed' if error else 'ok',
|
|
||||||
version = version,
|
|
||||||
xmlns = "http://subsonic.org/restapi"
|
|
||||||
)
|
|
||||||
|
|
||||||
elem = ElementTree.Element('subsonic-response')
|
|
||||||
ResponseHelper.dict2xml(elem, ret)
|
|
||||||
|
|
||||||
return minidom.parseString(ElementTree.tostring(elem)).toprettyxml(indent = ' ')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def dict2xml(elem, dictionary):
|
|
||||||
"""Convert a json structure to xml. The game is trivial. Nesting uses the [] parenthesis.
|
|
||||||
ex. { 'musicFolder': {'id': 1234, 'name': "sss" } }
|
|
||||||
ex. { 'musicFolder': [{'id': 1234, 'name': "sss" }, {'id': 456, 'name': "aaa" }]}
|
|
||||||
ex. { 'musicFolders': {'musicFolder' : [{'id': 1234, 'name': "sss" }, {'id': 456, 'name': "aaa" }] } }
|
|
||||||
ex. { 'index': [{'name': 'A', 'artist': [{'id': '517674445', 'name': 'Antonello Venditti'}] }] }
|
|
||||||
ex. {"subsonic-response": { "musicFolders": {"musicFolder": [{ "id": 0,"name": "Music"}]},
|
|
||||||
"status": "ok","version": "1.7.0","xmlns": "http://subsonic.org/restapi"}}
|
|
||||||
"""
|
|
||||||
if not isinstance(dictionary, dict):
|
|
||||||
raise TypeError('Expecting a dict')
|
|
||||||
if not all(map(lambda x: isinstance(x, strtype), dictionary)):
|
|
||||||
raise TypeError('Dictionary keys must be strings')
|
|
||||||
|
|
||||||
for name, value in dictionary.items():
|
|
||||||
if name == '_value_':
|
|
||||||
elem.text = ResponseHelper.value_tostring(value)
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
subelem = ElementTree.SubElement(elem, name)
|
|
||||||
ResponseHelper.dict2xml(subelem, value)
|
|
||||||
elif isinstance(value, list):
|
|
||||||
for v in value:
|
|
||||||
subelem = ElementTree.SubElement(elem, name)
|
|
||||||
if isinstance(v, dict):
|
|
||||||
ResponseHelper.dict2xml(subelem, v)
|
|
||||||
else:
|
|
||||||
subelem.text = ResponseHelper.value_tostring(v)
|
|
||||||
else:
|
|
||||||
elem.set(name, ResponseHelper.value_tostring(value))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def value_tostring(value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if isinstance(value, strtype):
|
|
||||||
return value
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return str(value).lower()
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
def get_entity(req, cls, param = 'id'):
|
def get_entity(req, cls, param = 'id'):
|
||||||
eid = req.values.get(param)
|
eid = req.values.get(param)
|
||||||
if not eid:
|
if not eid:
|
||||||
|
117
supysonic/api/formatters.py
Normal file
117
supysonic/api/formatters.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
#
|
||||||
|
# This file is part of Supysonic.
|
||||||
|
# Supysonic is a Python implementation of the Subsonic server API.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2018 Alban 'spl0k' Féron
|
||||||
|
#
|
||||||
|
# Distributed under terms of the GNU AGPLv3 license.
|
||||||
|
|
||||||
|
from flask import json, jsonify, make_response
|
||||||
|
from xml.dom import minidom
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from ..py23 import dict, strtype
|
||||||
|
from . import API_VERSION
|
||||||
|
|
||||||
|
def remove_empty_lists(d):
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
raise TypeError('Expecting a dict got ' + type(d).__name__)
|
||||||
|
|
||||||
|
keys_to_remove = []
|
||||||
|
for key, value in d.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
d[key] = remove_empty_lists(value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
if len(value) == 0:
|
||||||
|
keys_to_remove.append(key)
|
||||||
|
else:
|
||||||
|
d[key] = [ remove_empty_lists(item) if isinstance(item, dict) else item for item in value ]
|
||||||
|
|
||||||
|
for key in keys_to_remove:
|
||||||
|
del d[key]
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
|
def subsonicify(response, error):
|
||||||
|
rv = remove_empty_lists(response)
|
||||||
|
|
||||||
|
# add headers to response
|
||||||
|
rv.update(
|
||||||
|
status = 'failed' if error else 'ok',
|
||||||
|
version = API_VERSION
|
||||||
|
)
|
||||||
|
return { 'subsonic-response': rv }
|
||||||
|
|
||||||
|
def dict2xml(elem, dictionary):
|
||||||
|
"""Convert a json structure to xml. The game is trivial. Nesting uses the [] parenthesis.
|
||||||
|
ex. { 'musicFolder': {'id': 1234, 'name': "sss" } }
|
||||||
|
ex. { 'musicFolder': [{'id': 1234, 'name': "sss" }, {'id': 456, 'name': "aaa" }]}
|
||||||
|
ex. { 'musicFolders': {'musicFolder' : [{'id': 1234, 'name': "sss" }, {'id': 456, 'name': "aaa" }] } }
|
||||||
|
ex. { 'index': [{'name': 'A', 'artist': [{'id': '517674445', 'name': 'Antonello Venditti'}] }] }
|
||||||
|
ex. {"subsonic-response": { "musicFolders": {"musicFolder": [{ "id": 0,"name": "Music"}]},
|
||||||
|
"status": "ok","version": "1.7.0","xmlns": "http://subsonic.org/restapi"}}
|
||||||
|
"""
|
||||||
|
if not isinstance(dictionary, dict):
|
||||||
|
raise TypeError('Expecting a dict')
|
||||||
|
if not all(map(lambda x: isinstance(x, strtype), dictionary)):
|
||||||
|
raise TypeError('Dictionary keys must be strings')
|
||||||
|
|
||||||
|
for name, value in dictionary.items():
|
||||||
|
if name == '_value_':
|
||||||
|
elem.text = value_tostring(value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
subelem = ElementTree.SubElement(elem, name)
|
||||||
|
dict2xml(subelem, value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for v in value:
|
||||||
|
subelem = ElementTree.SubElement(elem, name)
|
||||||
|
if isinstance(v, dict):
|
||||||
|
dict2xml(subelem, v)
|
||||||
|
else:
|
||||||
|
subelem.text = value_tostring(v)
|
||||||
|
else:
|
||||||
|
elem.set(name, value_tostring(value))
|
||||||
|
|
||||||
|
def value_tostring(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, strtype):
|
||||||
|
return value
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return str(value).lower()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def make_json_response(response, error = False):
|
||||||
|
return jsonify(subsonicify(response, error))
|
||||||
|
|
||||||
|
def make_jsonp_response(response, callback, error = False):
|
||||||
|
if not callback:
|
||||||
|
return make_json_response(dict(error = dict(code = 10, message = 'Missing callback')), error = True)
|
||||||
|
|
||||||
|
rv = subsonicify(response, error)
|
||||||
|
rv = '{}({})'.format(callback, json.dumps(rv))
|
||||||
|
rv = make_response(rv)
|
||||||
|
rv.mimetype = 'application/javascript'
|
||||||
|
return rv
|
||||||
|
|
||||||
|
def make_xml_response(response, error = False):
|
||||||
|
response.update(
|
||||||
|
status = 'failed' if error else 'ok',
|
||||||
|
version = API_VERSION,
|
||||||
|
xmlns = "http://subsonic.org/restapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
elem = ElementTree.Element('subsonic-response')
|
||||||
|
dict2xml(elem, response)
|
||||||
|
|
||||||
|
rv = minidom.parseString(ElementTree.tostring(elem)).toprettyxml(indent = ' ')
|
||||||
|
rv = make_response(rv)
|
||||||
|
rv.mimetype = 'text/xml'
|
||||||
|
return rv
|
||||||
|
|
||||||
|
def make_error_response_func(f):
|
||||||
|
def make_error_response(code, message):
|
||||||
|
return f(dict(error = dict(code = code, message = message)), error = True)
|
||||||
|
return make_error_response
|
||||||
|
|
@ -111,7 +111,7 @@ class ApiSetupTestCase(TestBase):
|
|||||||
|
|
||||||
args.update({ 'f': 'jsonp' })
|
args.update({ 'f': 'jsonp' })
|
||||||
rv = self.client.get('/rest/getLicense.view', query_string = args)
|
rv = self.client.get('/rest/getLicense.view', query_string = args)
|
||||||
self.assertEqual(rv.mimetype, 'application/javascript')
|
self.assertEqual(rv.mimetype, 'application/json')
|
||||||
json = flask.json.loads(rv.data)
|
json = flask.json.loads(rv.data)
|
||||||
self.assertIn('subsonic-response', json)
|
self.assertIn('subsonic-response', json)
|
||||||
self.assertEqual(json['subsonic-response']['status'], 'failed')
|
self.assertEqual(json['subsonic-response']['status'], 'failed')
|
||||||
|
@ -22,15 +22,30 @@ class ResponseHelperBaseCase(TestBase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(ResponseHelperBaseCase, self).setUp()
|
super(ResponseHelperBaseCase, self).setUp()
|
||||||
|
|
||||||
from supysonic.api import ResponseHelper
|
from supysonic.api.formatters import make_json_response, make_jsonp_response, make_xml_response
|
||||||
self.helper = ResponseHelper
|
self.json = self.__json_unwrapper(make_json_response)
|
||||||
|
self.jsonp = self.__response_unwrapper(make_jsonp_response)
|
||||||
|
self.xml = self.__response_unwrapper(make_xml_response)
|
||||||
|
|
||||||
|
def __json_unwrapper(self, func):
|
||||||
|
def execute(*args, **kwargs):
|
||||||
|
with self.request_context():
|
||||||
|
rv = func(*args, **kwargs)
|
||||||
|
return rv.get_data(as_text = True)
|
||||||
|
return execute
|
||||||
|
|
||||||
|
def __response_unwrapper(self, func):
|
||||||
|
def execute(*args, **kwargs):
|
||||||
|
rv = func(*args, **kwargs)
|
||||||
|
return rv.get_data(as_text = True)
|
||||||
|
return execute
|
||||||
|
|
||||||
class ResponseHelperJsonTestCase(ResponseHelperBaseCase):
|
class ResponseHelperJsonTestCase(ResponseHelperBaseCase):
|
||||||
def serialize_and_deserialize(self, d, error = False):
|
def serialize_and_deserialize(self, d, error = False):
|
||||||
if not isinstance(d, dict):
|
if not isinstance(d, dict):
|
||||||
raise TypeError('Invalid tested value, expecting a dict')
|
raise TypeError('Invalid tested value, expecting a dict')
|
||||||
|
|
||||||
json = self.helper.responsize_json(d, error)
|
json = self.json(d, error)
|
||||||
return flask.json.loads(json)
|
return flask.json.loads(json)
|
||||||
|
|
||||||
def process_and_extract(self, d, error = False):
|
def process_and_extract(self, d, error = False):
|
||||||
@ -119,7 +134,7 @@ class ResponseHelperJsonTestCase(ResponseHelperBaseCase):
|
|||||||
|
|
||||||
class ResponseHelperJsonpTestCase(ResponseHelperBaseCase):
|
class ResponseHelperJsonpTestCase(ResponseHelperBaseCase):
|
||||||
def test_basic(self):
|
def test_basic(self):
|
||||||
result = self.helper.responsize_jsonp({}, 'callback')
|
result = self.jsonp({}, 'callback')
|
||||||
self.assertTrue(result.startswith('callback({'))
|
self.assertTrue(result.startswith('callback({'))
|
||||||
self.assertTrue(result.endswith('})'))
|
self.assertTrue(result.endswith('})'))
|
||||||
|
|
||||||
@ -128,7 +143,7 @@ class ResponseHelperJsonpTestCase(ResponseHelperBaseCase):
|
|||||||
|
|
||||||
class ResponseHelperXMLTestCase(ResponseHelperBaseCase):
|
class ResponseHelperXMLTestCase(ResponseHelperBaseCase):
|
||||||
def serialize_and_deserialize(self, d, error = False):
|
def serialize_and_deserialize(self, d, error = False):
|
||||||
xml = self.helper.responsize_xml(d, error)
|
xml = self.xml(d, error)
|
||||||
xml = xml.replace('xmlns="http://subsonic.org/restapi"', '')
|
xml = xml.replace('xmlns="http://subsonic.org/restapi"', '')
|
||||||
root = ElementTree.fromstring(xml)
|
root = ElementTree.fromstring(xml)
|
||||||
return root
|
return root
|
||||||
@ -138,7 +153,7 @@ class ResponseHelperXMLTestCase(ResponseHelperBaseCase):
|
|||||||
self.assertDictEqual(elem.attrib, d)
|
self.assertDictEqual(elem.attrib, d)
|
||||||
|
|
||||||
def test_root(self):
|
def test_root(self):
|
||||||
xml = self.helper.responsize_xml({ 'tag': {}})
|
xml = self.xml({ 'tag': {}})
|
||||||
self.assertIn('<subsonic-response ', xml)
|
self.assertIn('<subsonic-response ', xml)
|
||||||
self.assertIn('xmlns="http://subsonic.org/restapi"', xml)
|
self.assertIn('xmlns="http://subsonic.org/restapi"', xml)
|
||||||
self.assertTrue(xml.strip().endswith('</subsonic-response>'))
|
self.assertTrue(xml.strip().endswith('</subsonic-response>'))
|
||||||
|
@ -16,6 +16,8 @@ import sys
|
|||||||
import unittest
|
import unittest
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from supysonic.db import init_database, release_database
|
from supysonic.db import init_database, release_database
|
||||||
from supysonic.config import DefaultConfig
|
from supysonic.config import DefaultConfig
|
||||||
from supysonic.managers.user import UserManager
|
from supysonic.managers.user import UserManager
|
||||||
@ -93,11 +95,11 @@ class TestBase(unittest.TestCase):
|
|||||||
init_database(config.BASE['database_uri'], True)
|
init_database(config.BASE['database_uri'], True)
|
||||||
release_database()
|
release_database()
|
||||||
|
|
||||||
app = create_application(config)
|
self.__app = create_application(config)
|
||||||
self.__ctx = app.app_context()
|
self.__ctx = self.__app.app_context()
|
||||||
self.__ctx.push()
|
self.__ctx.push()
|
||||||
|
|
||||||
self.client = app.test_client()
|
self.client = self.__app.test_client()
|
||||||
|
|
||||||
UserManager.add('alice', 'Alic3', 'test@example.com', True)
|
UserManager.add('alice', 'Alic3', 'test@example.com', True)
|
||||||
UserManager.add('bob', 'B0b', 'bob@example.com', False)
|
UserManager.add('bob', 'B0b', 'bob@example.com', False)
|
||||||
@ -106,6 +108,13 @@ class TestBase(unittest.TestCase):
|
|||||||
self.client.get = patch_method(self.client.get)
|
self.client.get = patch_method(self.client.get)
|
||||||
self.client.post = patch_method(self.client.post)
|
self.client.post = patch_method(self.client.post)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def request_context(self, *args, **kwargs):
|
||||||
|
ctx = self.__app.test_request_context(*args, **kwargs)
|
||||||
|
ctx.push()
|
||||||
|
yield
|
||||||
|
ctx.pop()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __should_unload_module(module):
|
def __should_unload_module(module):
|
||||||
if module.startswith('supysonic'):
|
if module.startswith('supysonic'):
|
||||||
|
Loading…
Reference in New Issue
Block a user