From 1f18784f7a47e7f6ba2d9f84e6fc2e45ff4d2fba Mon Sep 17 00:00:00 2001 From: Petras Date: Thu, 24 Nov 2022 16:14:14 +0200 Subject: [PATCH 1/8] Supporting multiple APIs in one App --- flask_smorest/__init__.py | 10 +++-- flask_smorest/blueprint.py | 2 +- flask_smorest/etag.py | 19 +++++----- flask_smorest/spec/__init__.py | 67 +++++++++++++++++++++------------- flask_smorest/utils.py | 10 +++++ tests/test_api.py | 45 +++++++++++++++++++++-- tests/test_etag.py | 4 ++ 7 files changed, 113 insertions(+), 44 deletions(-) diff --git a/flask_smorest/__init__.py b/flask_smorest/__init__.py index 317592e4..59528cd3 100644 --- a/flask_smorest/__init__.py +++ b/flask_smorest/__init__.py @@ -39,9 +39,12 @@ class Api(APISpecMixin, ErrorHandlerMixin): parameter `API_SPEC_OPTIONS`. """ - def __init__(self, app=None, *, spec_kwargs=None): + def __init__(self, app=None, *, config_prefix=None, spec_kwargs=None): self._app = app self._spec_kwargs = spec_kwargs or {} + self.config_prefix = config_prefix or "" + if self.config_prefix and not self.config_prefix.endswith("_"): + self.config_prefix += "_" self.spec = None # Use lists to enforce order self._fields = [] @@ -59,8 +62,8 @@ def init_app(self, app, *, spec_kwargs=None): # Register flask-smorest in app extensions app.extensions = getattr(app, "extensions", {}) - ext = app.extensions.setdefault("flask-smorest", {}) - ext["ext_obj"] = self + ext = app.extensions.setdefault("flask-smorest", {"apis": {}}) + ext["apis"][self.config_prefix] = self # Initialize spec self._init_spec(**{**self._spec_kwargs, **(spec_kwargs or {})}) @@ -86,6 +89,7 @@ def register_blueprint(self, blp, *, parameters=None, **options): """ blp_name = options.get("name", blp.name) + blp.config_prefix = self.config_prefix # TODO: seems a little bit dirty self._app.register_blueprint(blp, **options) # Register views in API documentation for this resource diff --git a/flask_smorest/blueprint.py b/flask_smorest/blueprint.py index c8c001aa..107ac5e6 100644 --- a/flask_smorest/blueprint.py +++ b/flask_smorest/blueprint.py @@ -69,8 +69,8 @@ class Blueprint( DOCSTRING_INFO_DELIMITER = "---" def __init__(self, *args, **kwargs): - self.description = kwargs.pop("description", "") + self.config_prefix = "" super().__init__(*args, **kwargs) diff --git a/flask_smorest/etag.py b/flask_smorest/etag.py index 84ffe037..ac1b6c70 100644 --- a/flask_smorest/etag.py +++ b/flask_smorest/etag.py @@ -11,7 +11,7 @@ from flask import request, current_app from .exceptions import PreconditionRequired, PreconditionFailed, NotModified -from .utils import deepupdate, resolve_schema_instance, get_appcontext +from .utils import deepupdate, resolve_schema_instance, get_appcontext, get_config_value IF_NONE_MATCH_HEADER = { @@ -35,11 +35,6 @@ } -def _is_etag_enabled(): - """Return True if ETag feature enabled application-wise""" - return not current_app.config.get("ETAG_DISABLED", False) - - def _get_etag_ctx(): """Get ETag section of AppContext""" return get_appcontext().setdefault("etag", {}) @@ -73,7 +68,7 @@ def decorator(func): @wraps(func) def wrapper(*args, **kwargs): - etag_enabled = _is_etag_enabled() + etag_enabled = self._is_etag_enabled() if etag_enabled: # Check etag precondition @@ -142,7 +137,7 @@ def check_etag(self, etag_data, etag_schema=None): """ if request.method not in self.METHODS_NEEDING_CHECK_ETAG: warnings.warn(f"ETag cannot be checked on {request.method} request.") - if _is_etag_enabled(): + if self._is_etag_enabled(): if etag_schema is not None: etag_data = resolve_schema_instance(etag_schema).dump(etag_data) new_etag = self._generate_etag(etag_data) @@ -150,6 +145,10 @@ def check_etag(self, etag_data, etag_schema=None): if new_etag not in request.if_match: raise PreconditionFailed + def _is_etag_enabled(self, app=None): + """Return True if ETag feature enabled api-wise""" + return not get_config_value(app or current_app, self, "ETAG_DISABLED", False) + def _verify_check_etag(self): """Verify check_etag was called in resource code @@ -192,7 +191,7 @@ def set_etag(self, etag_data, etag_schema=None): """ if request.method not in self.METHODS_ALLOWING_SET_ETAG: warnings.warn(f"ETag cannot be set on {request.method} request.") - if _is_etag_enabled(): + if self._is_etag_enabled(): if etag_schema is not None: etag_data = resolve_schema_instance(etag_schema).dump(etag_data) new_etag = self._generate_etag(etag_data) @@ -223,7 +222,7 @@ def _set_etag_in_response(self, response): response.set_etag(new_etag) def _prepare_etag_doc(self, doc, doc_info, *, app, spec, method, **kwargs): - if doc_info.get("etag", False) and not app.config.get("ETAG_DISABLED", False): + if doc_info.get("etag", False) and self._is_etag_enabled(app): responses = {} method_u = method.upper() if method_u in self.METHODS_CHECKING_NOT_MODIFIED: diff --git a/flask_smorest/spec/__init__.py b/flask_smorest/spec/__init__.py index 76e8351f..0f59a552 100644 --- a/flask_smorest/spec/__init__.py +++ b/flask_smorest/spec/__init__.py @@ -1,14 +1,16 @@ """API specification using OpenAPI""" -import json import http +import json -import flask -from flask import current_app -import click import apispec +import click +import flask from apispec.ext.marshmallow import MarshmallowPlugin +from flask import current_app from webargs.fields import DelimitedList +from ..utils import get_config_value, get_config_key + try: # pragma: no cover import yaml @@ -16,12 +18,13 @@ except ImportError: # pragma: no cover HAS_PYYAML = False -from flask_smorest.exceptions import MissingAPIParameterError -from flask_smorest.utils import prepare_response from flask_smorest import etag as fs_etag from flask_smorest import pagination as fs_pagination -from .plugins import FlaskPlugin +from flask_smorest.exceptions import MissingAPIParameterError +from flask_smorest.utils import prepare_response + from .field_converters import uploadfield2properties +from .plugins import FlaskPlugin def _add_leading_slash(string): @@ -51,7 +54,7 @@ def _register_doc_blueprint(self): - json spec file - spec UI (ReDoc, Swagger UI). """ - api_url = self._app.config.get("OPENAPI_URL_PREFIX", None) + api_url = get_config_value(self._app, self, "OPENAPI_URL_PREFIX", None) if api_url is not None: blueprint = flask.Blueprint( "api-docs", @@ -60,7 +63,9 @@ def _register_doc_blueprint(self): template_folder="./templates", ) # Serve json spec at 'url_prefix/openapi.json' by default - json_path = self._app.config.get("OPENAPI_JSON_PATH", "openapi.json") + json_path = get_config_value( + self._app, self, "OPENAPI_JSON_PATH", "openapi.json" + ) blueprint.add_url_rule( _add_leading_slash(json_path), endpoint="openapi_json", @@ -76,9 +81,9 @@ def _register_redoc_rule(self, blueprint): The ReDoc script URL should be specified as OPENAPI_REDOC_URL. """ - redoc_path = self._app.config.get("OPENAPI_REDOC_PATH") + redoc_path = get_config_value(self._app, self, "OPENAPI_REDOC_PATH") if redoc_path is not None: - redoc_url = self._app.config.get("OPENAPI_REDOC_URL") + redoc_url = get_config_value(self._app, self, "OPENAPI_REDOC_URL") if redoc_url is not None: self._redoc_url = redoc_url blueprint.add_url_rule( @@ -93,9 +98,9 @@ def _register_swagger_ui_rule(self, blueprint): The Swagger UI scripts base URL should be specified as OPENAPI_SWAGGER_UI_URL. """ - swagger_ui_path = self._app.config.get("OPENAPI_SWAGGER_UI_PATH") + swagger_ui_path = get_config_value(self._app, self, "OPENAPI_SWAGGER_UI_PATH") if swagger_ui_path is not None: - swagger_ui_url = self._app.config.get("OPENAPI_SWAGGER_UI_URL") + swagger_ui_url = get_config_value(self._app, self, "OPENAPI_SWAGGER_UI_URL") if swagger_ui_url is not None: self._swagger_ui_url = swagger_ui_url blueprint.add_url_rule( @@ -109,9 +114,9 @@ def _register_rapidoc_rule(self, blueprint): The RapiDoc script URL should be specified as OPENAPI_RAPIDOC_URL. """ - rapidoc_path = self._app.config.get("OPENAPI_RAPIDOC_PATH") + rapidoc_path = get_config_value(self._app, self, "OPENAPI_RAPIDOC_PATH") if rapidoc_path is not None: - rapidoc_url = self._app.config.get("OPENAPI_RAPIDOC_URL") + rapidoc_url = get_config_value(self._app, self, "OPENAPI_RAPIDOC_URL") if rapidoc_url is not None: self._rapidoc_url = rapidoc_url blueprint.add_url_rule( @@ -140,7 +145,9 @@ def _openapi_swagger_ui(self): "swagger_ui.html", title=self.spec.title, swagger_ui_url=self._swagger_ui_url, - swagger_ui_config=self._app.config.get("OPENAPI_SWAGGER_UI_CONFIG", {}), + swagger_ui_config=get_config_value( + self._app, self, "OPENAPI_SWAGGER_UI_CONFIG", {} + ), ) def _openapi_rapidoc(self): @@ -149,7 +156,9 @@ def _openapi_rapidoc(self): "rapidoc.html", title=self.spec.title, rapidoc_url=self._rapidoc_url, - rapidoc_config=self._app.config.get("OPENAPI_RAPIDOC_CONFIG", {}), + rapidoc_config=get_config_value( + self._app, self, "OPENAPI_RAPIDOC_CONFIG", {} + ), ) @@ -170,7 +179,7 @@ def _init_spec( title=None, version=None, openapi_version=None, - **options + **options, ): # Plugins self.flask_plugin = flask_plugin or FlaskPlugin() @@ -179,22 +188,27 @@ def _init_spec( plugins.extend(extra_plugins or ()) # APISpec options - title = self._app.config.get("API_TITLE", title) + title = get_config_value(self._app, self, "API_TITLE", title) if title is None: + key = get_config_key(self, "API_TITLE") raise MissingAPIParameterError( - 'API title must be specified either as "API_TITLE" ' + f'API title must be specified either as "{key}" ' 'app parameter or as "title" spec kwarg.' ) - version = self._app.config.get("API_VERSION", version) + version = get_config_value(self._app, self, "API_VERSION", version) if version is None: + key = get_config_key(self, "API_VERSION") raise MissingAPIParameterError( - 'API version must be specified either as "API_VERSION" ' + f'API version must be specified either as "{key}" ' 'app parameter or as "version" spec kwarg.' ) - openapi_version = self._app.config.get("OPENAPI_VERSION", openapi_version) + openapi_version = get_config_value( + self._app, self, "OPENAPI_VERSION", openapi_version + ) if openapi_version is None: + key = get_config_key(self, "OPENAPI_VERSION") raise MissingAPIParameterError( - 'OpenAPI version must be specified either as "OPENAPI_VERSION ' + f'OpenAPI version must be specified either as "{key}" ' 'app parameter or as "openapi_version" spec kwarg.' ) openapi_major_version = int(openapi_version.split(".")[0]) @@ -211,7 +225,7 @@ def _init_spec( self.DEFAULT_REQUEST_BODY_CONTENT_TYPE, ], ) - options.update(self._app.config.get("API_SPEC_OPTIONS", {})) + options.update(get_config_value(self._app, self, "API_SPEC_OPTIONS", {})) # Instantiate spec self.spec = apispec.APISpec( @@ -363,7 +377,8 @@ def _register_pagination_header(self): def _get_spec_dict(): - return current_app.extensions["flask-smorest"]["ext_obj"].spec.to_dict() + # TODO: multiple apis + return current_app.extensions["flask-smorest"]["apis"][""].spec.to_dict() @openapi_cli.command("print") diff --git a/flask_smorest/utils.py b/flask_smorest/utils.py index e17eac65..ab8d4efe 100644 --- a/flask_smorest/utils.py +++ b/flask_smorest/utils.py @@ -137,3 +137,13 @@ def prepare_response(response, spec, content_type): field ] ) = response.pop(field) + + +def get_config_key(ctx, key): + """TODO: docstring""" + return getattr(ctx, "config_prefix", "") + key + + +def get_config_value(app, ctx, key, default=None): + """TODO: docstring""" + return app.config.get(get_config_key(ctx, key), default) diff --git a/tests/test_api.py b/tests/test_api.py index 964651c1..271b6b14 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,15 +1,16 @@ """Test Api class""" -import pytest +from types import SimpleNamespace +import apispec +import marshmallow as ma +import pytest from flask.views import MethodView from werkzeug.routing import BaseConverter -import marshmallow as ma -import apispec from flask_smorest import Api, Blueprint from flask_smorest.exceptions import MissingAPIParameterError -from .utils import get_schemas, get_responses +from .utils import get_responses, get_schemas class TestApi: @@ -412,3 +413,39 @@ def test(val): # Default error is now registered assert "DEFAULT_ERROR" in get_responses(api.spec) + + +class TestWithMultipleApiPerApp: + @pytest.mark.parametrize( + "app", + [ + SimpleNamespace( + API_TITLE="Ignore this title", + API_V1_API_TITLE="V1 Title", + API_V1_API_VERSION="1", + API_V1_OPENAPI_VERSION="2.0", + API_V2_API_TITLE="V2 Title", + API_V2_API_VERSION="2", + API_V2_OPENAPI_VERSION="3.0.2", + ) + ], + indirect=True, + ) + def test_config_prefix_attribute(self, app): + api1 = Api(app, config_prefix="API_V1_") + api2 = Api(app, config_prefix="API_V2") + + assert api1.spec.title == "V1 Title" + assert api2.spec.title == "V2 Title" + + @pytest.mark.parametrize( + "app", + [SimpleNamespace(API_TITLE="Ignore this title")], + indirect=True, + ) + def test_raises_error(self, app): + with pytest.raises( + MissingAPIParameterError, + match='API title must be specified either as "API_V1_API_TITLE"', + ): + Api(app, config_prefix="API_V1_") diff --git a/tests/test_etag.py b/tests/test_etag.py index 8c907fda..95a5482b 100644 --- a/tests/test_etag.py +++ b/tests/test_etag.py @@ -540,3 +540,7 @@ def test_etag_operations_etag_disabled(self, app_with_etag): f"/test/{item_2_id}", headers={"If-Match": "dummy_etag"} ) assert response.status_code == 204 + + def test_multiple_api_per_app(self): + # TODO: + pass From 2f66f21226b3780190ec2e6732fea37f212e8f6b Mon Sep 17 00:00:00 2001 From: Petras Date: Thu, 24 Nov 2022 18:16:14 +0200 Subject: [PATCH 2/8] Spec tests --- flask_smorest/__init__.py | 10 +++++++--- flask_smorest/spec/__init__.py | 20 +++++++++----------- tests/test_api.py | 6 ++---- tests/test_spec.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/flask_smorest/__init__.py b/flask_smorest/__init__.py index 59528cd3..5ea71f0c 100644 --- a/flask_smorest/__init__.py +++ b/flask_smorest/__init__.py @@ -17,6 +17,7 @@ class Api(APISpecMixin, ErrorHandlerMixin): :param Flask app: Flask application :param spec_kwargs: kwargs to pass to internal APISpec instance + :param config_prefix: TODO The ``spec_kwargs`` dictionary is passed as kwargs to the internal APISpec instance. **flask-smorest** adds a few parameters to the original @@ -39,10 +40,10 @@ class Api(APISpecMixin, ErrorHandlerMixin): parameter `API_SPEC_OPTIONS`. """ - def __init__(self, app=None, *, config_prefix=None, spec_kwargs=None): + def __init__(self, app=None, *, spec_kwargs=None, config_prefix=""): self._app = app self._spec_kwargs = spec_kwargs or {} - self.config_prefix = config_prefix or "" + self.config_prefix = config_prefix if self.config_prefix and not self.config_prefix.endswith("_"): self.config_prefix += "_" self.spec = None @@ -62,6 +63,7 @@ def init_app(self, app, *, spec_kwargs=None): # Register flask-smorest in app extensions app.extensions = getattr(app, "extensions", {}) + # TODO: backwards compatibility? ext = app.extensions.setdefault("flask-smorest", {"apis": {}}) ext["apis"][self.config_prefix] = self @@ -89,7 +91,9 @@ def register_blueprint(self, blp, *, parameters=None, **options): """ blp_name = options.get("name", blp.name) - blp.config_prefix = self.config_prefix # TODO: seems a little bit dirty + # TODO: seems a little bit dirty + if hasattr(blp, "config_prefix"): + blp.config_prefix = self.config_prefix self._app.register_blueprint(blp, **options) # Register views in API documentation for this resource diff --git a/flask_smorest/spec/__init__.py b/flask_smorest/spec/__init__.py index 0f59a552..fb6aeb5c 100644 --- a/flask_smorest/spec/__init__.py +++ b/flask_smorest/spec/__init__.py @@ -1,16 +1,14 @@ """API specification using OpenAPI""" -import http import json +import http -import apispec -import click import flask -from apispec.ext.marshmallow import MarshmallowPlugin from flask import current_app +import click +import apispec +from apispec.ext.marshmallow import MarshmallowPlugin from webargs.fields import DelimitedList -from ..utils import get_config_value, get_config_key - try: # pragma: no cover import yaml @@ -18,13 +16,12 @@ except ImportError: # pragma: no cover HAS_PYYAML = False +from flask_smorest.exceptions import MissingAPIParameterError +from flask_smorest.utils import prepare_response, get_config_key, get_config_value from flask_smorest import etag as fs_etag from flask_smorest import pagination as fs_pagination -from flask_smorest.exceptions import MissingAPIParameterError -from flask_smorest.utils import prepare_response - -from .field_converters import uploadfield2properties from .plugins import FlaskPlugin +from .field_converters import uploadfield2properties def _add_leading_slash(string): @@ -55,9 +52,10 @@ def _register_doc_blueprint(self): - spec UI (ReDoc, Swagger UI). """ api_url = get_config_value(self._app, self, "OPENAPI_URL_PREFIX", None) + bp_name = get_config_key(self, "api-docs").replace("_", "-").lower() if api_url is not None: blueprint = flask.Blueprint( - "api-docs", + bp_name, __name__, url_prefix=_add_leading_slash(api_url), template_folder="./templates", diff --git a/tests/test_api.py b/tests/test_api.py index 271b6b14..24cc193f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -414,8 +414,6 @@ def test(val): # Default error is now registered assert "DEFAULT_ERROR" in get_responses(api.spec) - -class TestWithMultipleApiPerApp: @pytest.mark.parametrize( "app", [ @@ -431,7 +429,7 @@ class TestWithMultipleApiPerApp: ], indirect=True, ) - def test_config_prefix_attribute(self, app): + def test_multiple_apis_using_config_prefix_attribute(self, app): api1 = Api(app, config_prefix="API_V1_") api2 = Api(app, config_prefix="API_V2") @@ -443,7 +441,7 @@ def test_config_prefix_attribute(self, app): [SimpleNamespace(API_TITLE="Ignore this title")], indirect=True, ) - def test_raises_error(self, app): + def test_prefixed_api_to_raise_correctly_formatted_error(self, app): with pytest.raises( MissingAPIParameterError, match='API title must be specified either as "API_V1_API_TITLE"', diff --git a/tests/test_spec.py b/tests/test_spec.py index 03ea05c0..dc91caf4 100644 --- a/tests/test_spec.py +++ b/tests/test_spec.py @@ -431,6 +431,39 @@ def test_apispec_serve_spec_preserve_order(self, app): assert response_json_docs.status_code == 200 assert response_json_docs.json["paths"] == paths + def test_multiple_apis_serve_separate_specs(self, app): + client = app.test_client() + + app.config["V1_OPENAPI_URL_PREFIX"] = "/v1-docs" + app.config["V2_OPENAPI_URL_PREFIX"] = "/v2-docs/" + + for i in range(1, 3): + api = Api( + app, + config_prefix=f"V{i}_", + spec_kwargs={ + "title": f"V{i}", + "version": f"{i}", + "openapi_version": "3.0.2", + }, + ) + blp = Blueprint(f"test{i}", f"test{i}", url_prefix=f"/test-{i}") + blp.route("/")(lambda: None) + api.register_blueprint(blp) + + json1 = client.get("/v1-docs/openapi.json").json + json2 = client.get("/v2-docs/openapi.json").json + + # Should have a different info + assert json1["info"]["title"] == "V1" + assert json2["info"]["title"] == "V2" + + # One api's routes should not leak into other's spec. + assert "/test-1/" in json1["paths"] + assert "/test-2/" not in json1["paths"] + assert "/test-1/" not in json2["paths"] + assert "/test-2/" in json2["paths"] + class TestAPISpecCLICommands: """Test OpenAPI CLI commands""" From e916ff7647c6bd8a5dc9b511b9aa890788d26771 Mon Sep 17 00:00:00 2001 From: Petras Date: Thu, 24 Nov 2022 18:43:07 +0200 Subject: [PATCH 3/8] Tests for Etag --- tests/test_api.py | 33 ++++++++++-------------------- tests/test_etag.py | 50 +++++++++++++++++++++++++++++++++++++++++++--- tests/test_spec.py | 2 +- 3 files changed, 59 insertions(+), 26 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 24cc193f..7ddb3d5f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,4 @@ """Test Api class""" -from types import SimpleNamespace - import apispec import marshmallow as ma import pytest @@ -414,33 +412,24 @@ def test(val): # Default error is now registered assert "DEFAULT_ERROR" in get_responses(api.spec) - @pytest.mark.parametrize( - "app", - [ - SimpleNamespace( - API_TITLE="Ignore this title", - API_V1_API_TITLE="V1 Title", - API_V1_API_VERSION="1", - API_V1_OPENAPI_VERSION="2.0", - API_V2_API_TITLE="V2 Title", - API_V2_API_VERSION="2", - API_V2_OPENAPI_VERSION="3.0.2", - ) - ], - indirect=True, - ) def test_multiple_apis_using_config_prefix_attribute(self, app): + app.config.update( + { + "API_TITLE": "Ignore this title", + "API_V1_API_TITLE": "V1 Title", + "API_V1_API_VERSION": "1", + "API_V1_OPENAPI_VERSION": "2.0", + "API_V2_API_TITLE": "V2 Title", + "API_V2_API_VERSION": "2", + "API_V2_OPENAPI_VERSION": "3.0.2", + } + ) api1 = Api(app, config_prefix="API_V1_") api2 = Api(app, config_prefix="API_V2") assert api1.spec.title == "V1 Title" assert api2.spec.title == "V2 Title" - @pytest.mark.parametrize( - "app", - [SimpleNamespace(API_TITLE="Ignore this title")], - indirect=True, - ) def test_prefixed_api_to_raise_correctly_formatted_error(self, app): with pytest.raises( MissingAPIParameterError, diff --git a/tests/test_etag.py b/tests/test_etag.py index 95a5482b..75978372 100644 --- a/tests/test_etag.py +++ b/tests/test_etag.py @@ -16,6 +16,7 @@ PreconditionFailed, ) from flask_smorest.utils import get_appcontext +import marshmallow as ma from .mocks import ItemNotFound @@ -541,6 +542,49 @@ def test_etag_operations_etag_disabled(self, app_with_etag): ) assert response.status_code == 204 - def test_multiple_api_per_app(self): - # TODO: - pass + @pytest.mark.parametrize("etag_disabled_for_v1", [False, True]) + @pytest.mark.parametrize("etag_disabled_for_v2", [False, True]) + def test_multiple_apis_per_app( + self, app, etag_disabled_for_v1, etag_disabled_for_v2 + ): + # All created APIs are using prefix. So default ETAG_DISABLED should be ignored + app.config["ETAG_DISABLED"] = True + app.config["V1_ETAG_DISABLED"] = etag_disabled_for_v1 + app.config["V2_ETAG_DISABLED"] = etag_disabled_for_v2 + + for i in [1, 2]: + api = Api( + app, + config_prefix=f"V{i}_", + spec_kwargs={ + "title": f"V{i}", + "version": f"{i}", + "openapi_version": "3.0.2", + }, + ) + blp = Blueprint(f"test{i}", f"test{i}", url_prefix=f"/test-{i}") + + class HomeSchema(ma.Schema): + field = ma.fields.String() + + @blp.route("/") + @blp.etag + @blp.response(200, HomeSchema) + def home(): + return {"field": "value"} + + api.register_blueprint(blp) + + client = app.test_client() + headers1 = client.get("/test-1/").headers + headers2 = client.get("/test-2/").headers + + if etag_disabled_for_v1: + assert "ETag" not in headers1 + else: + assert "ETag" in headers1 + + if etag_disabled_for_v2: + assert "ETag" not in headers2 + else: + assert "ETag" in headers2 diff --git a/tests/test_spec.py b/tests/test_spec.py index dc91caf4..b1c5549c 100644 --- a/tests/test_spec.py +++ b/tests/test_spec.py @@ -437,7 +437,7 @@ def test_multiple_apis_serve_separate_specs(self, app): app.config["V1_OPENAPI_URL_PREFIX"] = "/v1-docs" app.config["V2_OPENAPI_URL_PREFIX"] = "/v2-docs/" - for i in range(1, 3): + for i in [1, 2]: api = Api( app, config_prefix=f"V{i}_", From ba212e3c50b84340d9db4b47b86e275e751245ed Mon Sep 17 00:00:00 2001 From: Petras Date: Fri, 25 Nov 2022 14:43:35 +0200 Subject: [PATCH 4/8] Fixing all auto-doc rules --- flask_smorest/spec/__init__.py | 13 +++++++-- flask_smorest/spec/templates/rapidoc.html | 2 +- flask_smorest/spec/templates/redoc.html | 2 +- flask_smorest/spec/templates/swagger_ui.html | 2 +- tests/test_spec.py | 30 +++++++++++++++++--- 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/flask_smorest/spec/__init__.py b/flask_smorest/spec/__init__.py index fb6aeb5c..96e040a0 100644 --- a/flask_smorest/spec/__init__.py +++ b/flask_smorest/spec/__init__.py @@ -44,6 +44,9 @@ def delimited_list2param(self, field, **kwargs): class DocBlueprintMixin: """Extend Api to serve the spec in a dedicated blueprint.""" + def _get_doc_blueprint_name(self): + return get_config_key(self, "api-docs").replace("_", "-").lower() + def _register_doc_blueprint(self): """Register a blueprint in the application to expose the spec @@ -52,10 +55,9 @@ def _register_doc_blueprint(self): - spec UI (ReDoc, Swagger UI). """ api_url = get_config_value(self._app, self, "OPENAPI_URL_PREFIX", None) - bp_name = get_config_key(self, "api-docs").replace("_", "-").lower() if api_url is not None: blueprint = flask.Blueprint( - bp_name, + self._get_doc_blueprint_name(), __name__, url_prefix=_add_leading_slash(api_url), template_folder="./templates", @@ -134,7 +136,10 @@ def _openapi_json(self): def _openapi_redoc(self): """Expose OpenAPI spec with ReDoc""" return flask.render_template( - "redoc.html", title=self.spec.title, redoc_url=self._redoc_url + "redoc.html", + spec_url=flask.url_for(f"{self._get_doc_blueprint_name()}.openapi_json"), + title=self.spec.title, + redoc_url=self._redoc_url, ) def _openapi_swagger_ui(self): @@ -142,6 +147,7 @@ def _openapi_swagger_ui(self): return flask.render_template( "swagger_ui.html", title=self.spec.title, + spec_url=flask.url_for(f"{self._get_doc_blueprint_name()}.openapi_json"), swagger_ui_url=self._swagger_ui_url, swagger_ui_config=get_config_value( self._app, self, "OPENAPI_SWAGGER_UI_CONFIG", {} @@ -153,6 +159,7 @@ def _openapi_rapidoc(self): return flask.render_template( "rapidoc.html", title=self.spec.title, + spec_url=flask.url_for(f"{self._get_doc_blueprint_name()}.openapi_json"), rapidoc_url=self._rapidoc_url, rapidoc_config=get_config_value( self._app, self, "OPENAPI_RAPIDOC_CONFIG", {} diff --git a/flask_smorest/spec/templates/rapidoc.html b/flask_smorest/spec/templates/rapidoc.html index 659e1a4f..84f1779e 100644 --- a/flask_smorest/spec/templates/rapidoc.html +++ b/flask_smorest/spec/templates/rapidoc.html @@ -7,7 +7,7 @@ - + diff --git a/flask_smorest/spec/templates/swagger_ui.html b/flask_smorest/spec/templates/swagger_ui.html index bffd8ea4..bb40c109 100644 --- a/flask_smorest/spec/templates/swagger_ui.html +++ b/flask_smorest/spec/templates/swagger_ui.html @@ -15,7 +15,7 @@