diff --git a/doc/index.rst b/doc/index.rst index 3395b0ef..d6a03e35 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -51,6 +51,7 @@ Flask-RESTPlus with Flask. errors mask swagger + logging postman scaling example diff --git a/doc/logging.rst b/doc/logging.rst new file mode 100644 index 00000000..73d4f7f0 --- /dev/null +++ b/doc/logging.rst @@ -0,0 +1,103 @@ +Logging +=============== + +Flask-RESTPlus extends `Flask's logging `_ +by providing each ``API`` and ``Namespace`` it's own standard Python :class:`logging.Logger` instance. +This allows separation of logging on a per namespace basis to allow more fine-grained detail and configuration. + +By default, these loggers inherit configuration from the Flask application object logger. + +.. code-block:: python + + import logging + + import flask + + from flask_restplus import Api, Resource + + # configure root logger + logging.basicConfig(level=logging.INFO) + + app = flask.Flask(__name__) + + api = Api(app) + + + # each of these loggers uses configuration from app.logger + ns1 = api.namespace('api/v1', description='test') + ns2 = api.namespace('api/v2', description='test') + + + @ns1.route('/my-resource') + class MyResource(Resource): + def get(self): + # will log + ns1.logger.info("hello from ns1") + return {"message": "hello"} + + + @ns2.route('/my-resource') + class MyNewResource(Resource): + def get(self): + # won't log due to INFO log level from app.logger + ns2.logger.debug("hello from ns2") + return {"message": "hello"} + + +Loggers can be configured individually to override the configuration from the Flask +application object logger. In the above example, ``ns2`` log level can be set to +``DEBUG`` individually: + +.. code-block:: python + + # ns1 will have log level INFO from app.logger + ns1 = api.namespace('api/v1', description='test') + + # ns2 will have log level DEBUG + ns2 = api.namespace('api/v2', description='test') + ns2.logger.setLevel(logging.DEBUG) + + + @ns1.route('/my-resource') + class MyResource(Resource): + def get(self): + # will log + ns1.logger.info("hello from ns1") + return {"message": "hello"} + + + @ns2.route('/my-resource') + class MyNewResource(Resource): + def get(self): + # will log + ns2.logger.debug("hello from ns2") + return {"message": "hello"} + + +Adding additional handlers: + + +.. code-block:: python + + # configure a file handler for ns1 only + ns1 = api.namespace('api/v1') + fh = logging.FileHandler("v1.log") + ns1.logger.addHandler(fh) + + ns2 = api.namespace('api/v2') + + + @ns1.route('/my-resource') + class MyResource(Resource): + def get(self): + # will log to *both* v1.log file and app.logger handlers + ns1.logger.info("hello from ns1") + return {"message": "hello"} + + + @ns2.route('/my-resource') + class MyNewResource(Resource): + def get(self): + # will log to *only* app.logger handlers + ns2.logger.info("hello from ns2") + return {"message": "hello"} \ No newline at end of file diff --git a/flask_restplus/api.py b/flask_restplus/api.py index 37276503..a2889ab4 100644 --- a/flask_restplus/api.py +++ b/flask_restplus/api.py @@ -126,12 +126,7 @@ def __init__(self, app=None, version='1.0', title=None, description=None, self._refresolver = None self.format_checker = format_checker self.namespaces = [] - self.default_namespace = self.namespace(default, default_label, - endpoint='{0}-declaration'.format(default), - validate=validate, - api=self, - path='/', - ) + self.ns_paths = dict() self.representations = OrderedDict(DEFAULT_REPRESENTATIONS) @@ -146,7 +141,14 @@ def __init__(self, app=None, version='1.0', title=None, description=None, self.resources = [] self.app = None self.blueprint = None - + # must come after self.app initialisation to prevent __getattr__ recursion + # in self._configure_namespace_logger + self.default_namespace = self.namespace(default, default_label, + endpoint='{0}-declaration'.format(default), + validate=validate, + api=self, + path='/', + ) if app is not None: self.app = app self.init_app(app) @@ -205,6 +207,9 @@ def _init_app(self, app): for resource, namespace, urls, kwargs in self.resources: self._register_view(app, resource, namespace, *urls, **kwargs) + for ns in self.namespaces: + self._configure_namespace_logger(app, ns) + self._register_apidoc(app) self._validate = self._validate if self._validate is not None else app.config.get('RESTPLUS_VALIDATE', False) app.config.setdefault('RESTPLUS_MASK_HEADER', 'X-Fields') @@ -266,6 +271,11 @@ def register_resource(self, namespace, resource, *urls, **kwargs): self.resources.append((resource, namespace, urls, kwargs)) return endpoint + def _configure_namespace_logger(self, app, namespace): + for handler in app.logger.handlers: + namespace.logger.addHandler(handler) + namespace.logger.setLevel(app.logger.level) + def _register_view(self, app, resource, namespace, *urls, **kwargs): endpoint = kwargs.pop('endpoint', None) or camel_to_dash(resource.__name__) resource_class_args = kwargs.pop('resource_class_args', ()) @@ -427,6 +437,8 @@ def add_namespace(self, ns, path=None): # Register models for name, definition in six.iteritems(ns.models): self.models[name] = definition + if not self.blueprint and self.app is not None: + self._configure_namespace_logger(self.app, ns) def namespace(self, *args, **kwargs): ''' diff --git a/flask_restplus/namespace.py b/flask_restplus/namespace.py index 8e8e11d2..b338c54b 100644 --- a/flask_restplus/namespace.py +++ b/flask_restplus/namespace.py @@ -3,6 +3,7 @@ import inspect import warnings +import logging from collections import namedtuple import six @@ -53,6 +54,7 @@ def __init__(self, name, description=None, path=None, decorators=None, validate= self.apis = [] if 'api' in kwargs: self.apis.append(kwargs['api']) + self.logger = logging.getLogger(__name__ + "." + self.name) @property def path(self): diff --git a/tests/conftest.py b/tests/conftest.py index 2d21c0ad..0b10b95c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -53,6 +53,18 @@ def api(request, app): yield api +@pytest.fixture +def mock_app(mocker): + app = mocker.Mock(Flask) + # mock Flask app object doesn't have any real loggers -> mock logging + # set up on Api object + mocker.patch.object(restplus.Api, '_configure_namespace_logger') + app.view_functions = {} + app.extensions = {} + app.config = {} + return app + + @pytest.fixture(autouse=True) def _push_custom_request_context(request): app = request.getfixturevalue('app') diff --git a/tests/legacy/test_api_legacy.py b/tests/legacy/test_api_legacy.py index 7b10725b..88d0b8a8 100644 --- a/tests/legacy/test_api_legacy.py +++ b/tests/legacy/test_api_legacy.py @@ -126,21 +126,17 @@ def test_media_types_q(self, app): }): assert api.mediatypes() == ['application/json', 'application/xml'] - def test_decorator(self, mocker): + def test_decorator(self, mocker, mock_app): def return_zero(func): return 0 - app = mocker.Mock(flask.Flask) - app.view_functions = {} - app.extensions = {} - app.config = {} view = mocker.Mock() - api = restplus.Api(app) + api = restplus.Api(mock_app) api.decorators.append(return_zero) api.output = mocker.Mock() api.add_resource(view, '/foo', endpoint='bar') - app.add_url_rule.assert_called_with('/foo', view_func=0) + mock_app.add_url_rule.assert_called_with('/foo', view_func=0) def test_add_resource_endpoint(self, app, mocker): view = mocker.Mock(**{'as_view.return_value.__name__': str('test_view')}) @@ -181,28 +177,20 @@ def get(self): foo2 = client.get('/foo/toto') assert foo2.data == b'"foo1"\n' - def test_add_resource(self, mocker): - app = mocker.Mock(flask.Flask) - app.view_functions = {} - app.extensions = {} - app.config = {} - api = restplus.Api(app) + def test_add_resource(self, mocker, mock_app): + api = restplus.Api(mock_app) api.output = mocker.Mock() api.add_resource(views.MethodView, '/foo') - app.add_url_rule.assert_called_with('/foo', + mock_app.add_url_rule.assert_called_with('/foo', view_func=api.output()) - def test_add_resource_kwargs(self, mocker): - app = mocker.Mock(flask.Flask) - app.view_functions = {} - app.extensions = {} - app.config = {} - api = restplus.Api(app) + def test_add_resource_kwargs(self, mocker, mock_app): + api = restplus.Api(mock_app) api.output = mocker.Mock() api.add_resource(views.MethodView, '/foo', defaults={"bar": "baz"}) - app.add_url_rule.assert_called_with('/foo', + mock_app.add_url_rule.assert_called_with('/foo', view_func=api.output(), defaults={"bar": "baz"}) diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 00000000..e3ed832e --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,133 @@ +import logging + +import flask_restplus as restplus + + +class LoggingTest(object): + def test_namespace_loggers_log_to_flask_app_logger(self, app, client, caplog): + # capture Flask app logs + caplog.set_level(logging.INFO, logger=app.logger.name) + + api = restplus.Api(app) + ns1 = api.namespace('ns1', path='/ns1') + ns2 = api.namespace('ns2', path='/ns2') + + @ns1.route('/') + class Ns1(restplus.Resource): + def get(self): + ns1.logger.info("hello from ns1") + pass + + @ns2.route('/') + class Ns2(restplus.Resource): + def get(self): + ns2.logger.info("hello from ns2") + pass + + # debug log not shown + client.get("/ns1/") + matching = [r for r in caplog.records if r.message == "hello from ns1"] + assert len(matching) == 1 + + # info log shown + client.get("/ns2/") + matching = [r for r in caplog.records if r.message == "hello from ns2"] + assert len(matching) == 1 + + def test_defaults_to_app_level(self, app, client, caplog): + caplog.set_level(logging.INFO, logger=app.logger.name) + + api = restplus.Api(app) + ns1 = api.namespace('ns1', path='/ns1') + ns2 = api.namespace('ns2', path='/ns2') + + @ns1.route('/') + class Ns1(restplus.Resource): + def get(self): + ns1.logger.debug("hello from ns1") + pass + + @ns2.route('/') + class Ns2(restplus.Resource): + def get(self): + ns2.logger.info("hello from ns2") + pass + + # debug log not shown + client.get("/ns1/") + matching = [r for r in caplog.records if r.message == "hello from ns1"] + assert len(matching) == 0 + + # info log shown + client.get("/ns2/") + matching = [r for r in caplog.records if r.message == "hello from ns2"] + assert len(matching) == 1 + + def test_override_app_level(self, app, client, caplog): + caplog.set_level(logging.INFO, logger=app.logger.name) + + api = restplus.Api(app) + ns1 = api.namespace('ns1', path='/ns1') + ns1.logger.setLevel(logging.DEBUG) + ns2 = api.namespace('ns2', path='/ns2') + + @ns1.route('/') + class Ns1(restplus.Resource): + def get(self): + ns1.logger.debug("hello from ns1") + pass + + @ns2.route('/') + class Ns2(restplus.Resource): + def get(self): + ns2.logger.debug("hello from ns2") + pass + + # debug log shown from ns1 + client.get("/ns1/") + matching = [r for r in caplog.records if r.message == "hello from ns1"] + assert len(matching) == 1 + + # debug not shown from ns2 + client.get("/ns2/") + matching = [r for r in caplog.records if r.message == "hello from ns2"] + assert len(matching) == 0 + + def test_namespace_additional_handler(self, app, client, caplog, tmp_path): + caplog.set_level(logging.INFO, logger=app.logger.name) + log_file = tmp_path / "v1.log" + + api = restplus.Api(app) + ns1 = api.namespace('ns1', path='/ns1') + # set up a file handler for ns1 only + # FileHandler only supports Path object on Python >= 3.6 -> cast to str + fh = logging.FileHandler(str(log_file)) + ns1.logger.addHandler(fh) + + ns2 = api.namespace('ns2', path='/ns2') + + @ns1.route('/') + class Ns1(restplus.Resource): + def get(self): + ns1.logger.info("hello from ns1") + pass + + @ns2.route('/') + class Ns2(restplus.Resource): + def get(self): + ns2.logger.info("hello from ns2") + pass + + # ns1 logs go to stdout and log_file + client.get("/ns1/") + matching = [r for r in caplog.records if r.message == "hello from ns1"] + assert len(matching) == 1 + with log_file.open() as f: + assert "hello from ns1" in f.read() + + # ns2 logs go to stdout only + client.get("/ns2/") + matching = [r for r in caplog.records if r.message == "hello from ns2"] + assert len(matching) == 1 + with log_file.open() as f: + assert "hello from ns1" in f.read()