diff --git a/docs/index.rst b/docs/index.rst index 2b1fd96..bea0328 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -81,7 +81,9 @@ Flask-PyMongo provides helpers for some common tasks: .. automethod:: flask_pymongo.PyMongo.save_file -.. autoclass:: flask_pymongo.BSONObjectIdConverter +.. autoclass:: flask_pymongo.helpers.BSONObjectIdConverter + +.. autoclass:: flask_pymongo.helpers.JSONEncoder Configuration ------------- @@ -92,6 +94,13 @@ You can configure Flask-PyMongo either by passing a `MongoDB URI ``MONGO_URI`` `Flask configuration variable `_ +The :class:`~flask_pymongo.PyMongo` instnace also accepts these additional +customization options: + +* ``json_options``, a :class:`~bson.json_util.JSONOptions` instance which + controls the JSON serialization of MongoDB objects when used with + :func:`~flask.json.jsonify`. + You may also pass additional keyword arguments to the ``PyMongo`` constructor. These are passed directly through to the underlying :class:`~pymongo.mongo_client.MongoClient` object. @@ -179,6 +188,8 @@ Changes: MongoDB 3.2 support. - `#130 `_ Fix quickstart example in README (Emmanuel Arias). + - `#62 `_ Add + support for :func:`~flask.json.jsonify()`. - 2.3.0: April 24, 2019 diff --git a/flask_pymongo/__init__.py b/flask_pymongo/__init__.py index 1b3200c..896c485 100644 --- a/flask_pymongo/__init__.py +++ b/flask_pymongo/__init__.py @@ -26,18 +26,17 @@ __all__ = ("PyMongo", "ASCENDING", "DESCENDING") +from functools import partial from mimetypes import guess_type import sys -from bson.errors import InvalidId -from bson.objectid import ObjectId from flask import abort, current_app, request from gridfs import GridFS, NoFile from pymongo import uri_parser -from werkzeug.routing import BaseConverter from werkzeug.wsgi import wrap_file import pymongo +from flask_pymongo.helpers import BSONObjectIdConverter, JSONEncoder from flask_pymongo.wrappers import MongoClient @@ -59,35 +58,6 @@ """Ascending sort order.""" -class BSONObjectIdConverter(BaseConverter): - - """A simple converter for the RESTful URL routing system of Flask. - - .. code-block:: python - - @app.route("/") - def show_task(task_id): - task = mongo.db.tasks.find_one_or_404(task_id) - return render_template("task.html", task=task) - - Valid object ID strings are converted into - :class:`~bson.objectid.ObjectId` objects; invalid strings result - in a 404 error. The converter is automatically registered by the - initialization of :class:`~flask_pymongo.PyMongo` with keyword - :attr:`ObjectId`. - - """ - - def to_python(self, value): - try: - return ObjectId(value) - except InvalidId: - raise abort(404) - - def to_url(self, value): - return str(value) - - class PyMongo(object): """Manages MongoDB connections for your Flask app. @@ -102,9 +72,10 @@ class PyMongo(object): """ - def __init__(self, app=None, uri=None, *args, **kwargs): + def __init__(self, app=None, uri=None, json_options=None, *args, **kwargs): self.cx = None self.db = None + self._json_encoder = partial(JSONEncoder, json_options=json_options) if app is not None: self.init_app(app, uri, *args, **kwargs) @@ -156,6 +127,7 @@ def init_app(self, app, uri=None, *args, **kwargs): self.db = self.cx[database_name] app.url_map.converters["ObjectId"] = BSONObjectIdConverter + app.json_encoder = self._json_encoder # view helpers def send_file(self, filename, base="fs", version=-1, cache_for=31536000): diff --git a/flask_pymongo/helpers.py b/flask_pymongo/helpers.py new file mode 100644 index 0000000..b1fb4ea --- /dev/null +++ b/flask_pymongo/helpers.py @@ -0,0 +1,154 @@ +# Copyright (c) 2011-2019, Dan Crosta +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + + +__all__ = ("BSONObjectIdConverter", "JSONEncoder") + +from bson import json_util, SON +from bson.errors import InvalidId +from bson.objectid import ObjectId +from flask import abort, json as flask_json +from six import iteritems, string_types +from werkzeug.routing import BaseConverter +import pymongo + +if pymongo.version_tuple >= (3, 5, 0): + from bson.json_util import RELAXED_JSON_OPTIONS + DEFAULT_JSON_OPTIONS = RELAXED_JSON_OPTIONS +else: + DEFAULT_JSON_OPTIONS = None + + +def _iteritems(obj): + if hasattr(obj, "iteritems"): + return obj.iteritems() + elif hasattr(obj, "items"): + return obj.items() + else: + raise TypeError("{!r} missing iteritems() and items()".format(obj)) + + +class BSONObjectIdConverter(BaseConverter): + + """A simple converter for the RESTful URL routing system of Flask. + + .. code-block:: python + + @app.route("/") + def show_task(task_id): + task = mongo.db.tasks.find_one_or_404(task_id) + return render_template("task.html", task=task) + + Valid object ID strings are converted into + :class:`~bson.objectid.ObjectId` objects; invalid strings result + in a 404 error. The converter is automatically registered by the + initialization of :class:`~flask_pymongo.PyMongo` with keyword + :attr:`ObjectId`. + + The :class:`~flask_pymongo.helpers.BSONObjectIdConverter` is + automatically installed on the :class:`~flask_pymongo.PyMongo` + instnace at creation time. + + """ + + def to_python(self, value): + try: + return ObjectId(value) + except InvalidId: + raise abort(404) + + def to_url(self, value): + return str(value) + + +class JSONEncoder(flask_json.JSONEncoder): + + """A JSON encoder that uses :mod:`bson.json_util` for MongoDB documents. + + .. code-block:: python + + @app.route("/cart/") + def json_route(cart_id): + results = mongo.db.carts.find({"_id": cart_id}) + return jsonify(results) + + # returns a Response with JSON body and application/json content-type: + # '[{"count":12,"item":"egg"},{"count":1,"item":"apple"}]' + + Since this uses PyMongo's JSON tools, certain types may serialize + differently than you expect. See :class:`~bson.json_util.JSONOptions` + for details on the particular serialization that will be used. + + A :class:`~flask_pymongo.helpers.JSONEncoder` is automatically + automatically installed on the :class:`~flask_pymongo.PyMongo` + instance at creation time, using + :const:`~bson.json_util.RELAXED_JSON_OPTIONS`. You can change the + :class:`~bson.json_util.JSONOptions` in use by passing + ``json_options`` to the :class:`~flask_pymongo.PyMongo` + constructor. + + .. note:: + + :class:`~bson.json_util.JSONOptions` is only supported as of + PyMongo version 3.4. For older versions of PyMongo, you will + have less control over the JSON format that results from calls + to :func:`~flask.json.jsonify`. + + .. versionadded:: 2.4.0 + + """ + + def __init__(self, json_options, *args, **kwargs): + if json_options is None: + json_options = DEFAULT_JSON_OPTIONS + if json_options is not None: + self._default_kwargs = {"json_options": json_options} + else: + self._default_kwargs = {} + + super(JSONEncoder, self).__init__(*args, **kwargs) + + def default(self, obj): + """Serialize MongoDB object types using :mod:`bson.json_util`. + + Falls back to Flask's default JSON serialization for all other types. + + This may raise ``TypeError`` for object types not recignozed. + + .. versionadded:: 2.4.0 + + """ + if hasattr(obj, "iteritems") or hasattr(obj, "items"): + return SON((k, self.default(v)) for k, v in iteritems(obj)) + elif hasattr(obj, "__iter__") and not isinstance(obj, string_types): + return [self.default(v) for v in obj] + else: + try: + return json_util.default(obj, **self._default_kwargs) + except TypeError: + # PyMongo couldn't convert into a serializable object, and + # the Flask default JSONEncoder won't; so we return the + # object itself and let stdlib json handle it if possible + return obj diff --git a/flask_pymongo/tests/test_json.py b/flask_pymongo/tests/test_json.py new file mode 100644 index 0000000..93d179d --- /dev/null +++ b/flask_pymongo/tests/test_json.py @@ -0,0 +1,29 @@ +import json + +from bson import ObjectId +from flask import jsonify +from six import ensure_str + +from flask_pymongo.tests.util import FlaskPyMongoTest + + +class JSONTest(FlaskPyMongoTest): + + def test_it_encodes_json(self): + resp = jsonify({"foo": "bar"}) + dumped = json.loads(ensure_str(resp.get_data())) + self.assertEqual(dumped, {"foo": "bar"}) + + def test_it_handles_pymongo_types(self): + resp = jsonify({"id": ObjectId("5cf29abb5167a14c9e6e12c4")}) + dumped = json.loads(ensure_str(resp.get_data())) + self.assertEqual(dumped, {"id": {"$oid": "5cf29abb5167a14c9e6e12c4"}}) + + def test_it_jsonifies_a_cursor(self): + self.mongo.db.rows.insert_many([{"foo": "bar"}, {"foo": "baz"}]) + + curs = self.mongo.db.rows.find(projection={"_id": False}).sort("foo") + + resp = jsonify(curs) + dumped = json.loads(ensure_str(resp.get_data())) + self.assertEqual([{"foo": "bar"}, {"foo": "baz"}], dumped) diff --git a/setup.py b/setup.py index cb44097..a1d04bd 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ install_requires=[ "Flask>=0.11", "PyMongo>=3.3", + "six", ], classifiers=[ "Environment :: Web Environment",