diff --git a/AUTHORS.rst b/AUTHORS.rst index 754e6b8d..5eb59fac 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -25,3 +25,4 @@ Contributors (chronological) - Choudhury Noor `@Cnoor0171 `_ - Dmitry Erlikh `@derlikh-smart `_ - 0x78f1935 `@0x78f1935 `_ +- One Codex, Inc. `@onecodex `_ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 55accdeb..338322d5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,11 @@ Changelog 0.41.0 (unreleased) ******************* +Features: + +- Support multiple APIs in a single application via ``Api.__init__`` param + ``config_prefix``. + Other changes: - Official Python 3.11 support (:pr:`416`). diff --git a/docs/openapi.rst b/docs/openapi.rst index df3ff962..f129810d 100644 --- a/docs/openapi.rst +++ b/docs/openapi.rst @@ -280,6 +280,19 @@ interactively. This feature is accessible through Flask app parameters. Default: ``openapi.json`` +`Note`: To support multiple APIs in the single application a ``config_prefix`` +parameter needs to be passed to :class:`Api ` constructor. In that case +Flask app parameters need to be prefixed with a custom prefix. + +.. code-block:: python + + api1 = Api(config_prefix="V1_") + + + class Config: + V1_OPENAPI_VERSION = "3.0.2" # Instead of OPENAPI_VERSION + V1_OPENAPI_URL_PREFIX = "/v1/" # Instead of OPENAPI_URL_PREFIX + `ReDoc`_, `Swagger UI`_ and `RapiDoc` interfaces are available to present the API. diff --git a/flask_smorest/__init__.py b/flask_smorest/__init__.py index 317592e4..75c307fa 100644 --- a/flask_smorest/__init__.py +++ b/flask_smorest/__init__.py @@ -6,6 +6,7 @@ from .blueprint import Blueprint # noqa from .pagination import Page # noqa from .error_handler import ErrorHandlerMixin +from .utils import normalize_config_prefix __version__ = "0.40.0" @@ -17,6 +18,11 @@ class Api(APISpecMixin, ErrorHandlerMixin): :param Flask app: Flask application :param spec_kwargs: kwargs to pass to internal APISpec instance + :param str config_prefix: Should be used if the user is planning to use + multiple `Api`'s in a single app. If it is not empty then + all application parameters will be prefixed with it. For example: + if ``config_prefix`` is ``V1_`` then ``V1_API_TITLE`` is going to + be used instead of ``API_TITLE``. The ``spec_kwargs`` dictionary is passed as kwargs to the internal APISpec instance. **flask-smorest** adds a few parameters to the original @@ -39,9 +45,10 @@ class Api(APISpecMixin, ErrorHandlerMixin): parameter `API_SPEC_OPTIONS`. """ - def __init__(self, app=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 = normalize_config_prefix(config_prefix) self.spec = None # Use lists to enforce order self._fields = [] @@ -59,8 +66,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 {})}) @@ -85,7 +92,8 @@ def register_blueprint(self, blp, *, parameters=None, **options): Must be called after app is initialized. """ blp_name = options.get("name", blp.name) - + 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/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..552b4eb7 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,12 @@ 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=app or current_app, ctx=self, key="ETAG_DISABLED", default=False + ) + def _verify_check_etag(self): """Verify check_etag was called in resource code @@ -192,7 +193,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 +224,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..038ef717 100644 --- a/flask_smorest/spec/__init__.py +++ b/flask_smorest/spec/__init__.py @@ -17,7 +17,12 @@ HAS_PYYAML = False from flask_smorest.exceptions import MissingAPIParameterError -from flask_smorest.utils import prepare_response +from flask_smorest.utils import ( + prepare_response, + get_config_key, + get_config_value, + normalize_config_prefix, +) from flask_smorest import etag as fs_etag from flask_smorest import pagination as fs_pagination from .plugins import FlaskPlugin @@ -44,6 +49,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(ctx=self, key="api-docs").replace("_", "-").lower() + def _register_doc_blueprint(self): """Register a blueprint in the application to expose the spec @@ -51,16 +59,18 @@ 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(app=self._app, ctx=self, key="OPENAPI_URL_PREFIX") if api_url is not None: blueprint = flask.Blueprint( - "api-docs", + self._get_doc_blueprint_name(), __name__, url_prefix=_add_leading_slash(api_url), 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( + app=self._app, ctx=self, key="OPENAPI_JSON_PATH", default="openapi.json" + ) blueprint.add_url_rule( _add_leading_slash(json_path), endpoint="openapi_json", @@ -76,9 +86,11 @@ 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(app=self._app, ctx=self, key="OPENAPI_REDOC_PATH") if redoc_path is not None: - redoc_url = self._app.config.get("OPENAPI_REDOC_URL") + redoc_url = get_config_value( + app=self._app, ctx=self, key="OPENAPI_REDOC_URL" + ) if redoc_url is not None: self._redoc_url = redoc_url blueprint.add_url_rule( @@ -93,9 +105,13 @@ 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( + app=self._app, ctx=self, key="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( + app=self._app, ctx=self, key="OPENAPI_SWAGGER_UI_URL" + ) if swagger_ui_url is not None: self._swagger_ui_url = swagger_ui_url blueprint.add_url_rule( @@ -109,9 +125,13 @@ 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( + app=self._app, ctx=self, key="OPENAPI_RAPIDOC_PATH" + ) if rapidoc_path is not None: - rapidoc_url = self._app.config.get("OPENAPI_RAPIDOC_URL") + rapidoc_url = get_config_value( + app=self._app, ctx=self, key="OPENAPI_RAPIDOC_URL" + ) if rapidoc_url is not None: self._rapidoc_url = rapidoc_url blueprint.add_url_rule( @@ -131,7 +151,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): @@ -139,8 +162,11 @@ 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=self._app.config.get("OPENAPI_SWAGGER_UI_CONFIG", {}), + swagger_ui_config=get_config_value( + app=self._app, ctx=self, key="OPENAPI_SWAGGER_UI_CONFIG", default={} + ), ) def _openapi_rapidoc(self): @@ -148,8 +174,11 @@ 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=self._app.config.get("OPENAPI_RAPIDOC_CONFIG", {}), + rapidoc_config=get_config_value( + app=self._app, ctx=self, key="OPENAPI_RAPIDOC_CONFIG", default={} + ), ) @@ -170,7 +199,7 @@ def _init_spec( title=None, version=None, openapi_version=None, - **options + **options, ): # Plugins self.flask_plugin = flask_plugin or FlaskPlugin() @@ -179,22 +208,31 @@ def _init_spec( plugins.extend(extra_plugins or ()) # APISpec options - title = self._app.config.get("API_TITLE", title) + title = get_config_value( + app=self._app, ctx=self, key="API_TITLE", default=title + ) if title is None: + key = get_config_key(ctx=self, key="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( + app=self._app, ctx=self, key="API_VERSION", default=version + ) if version is None: + key = get_config_key(ctx=self, key="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( + app=self._app, ctx=self, key="OPENAPI_VERSION", default=openapi_version + ) if openapi_version is None: + key = get_config_key(ctx=self, key="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 +249,11 @@ def _init_spec( self.DEFAULT_REQUEST_BODY_CONTENT_TYPE, ], ) - options.update(self._app.config.get("API_SPEC_OPTIONS", {})) + options.update( + get_config_value( + app=self._app, ctx=self, key="API_SPEC_OPTIONS", default={} + ) + ) # Instantiate spec self.spec = apispec.APISpec( @@ -362,19 +404,35 @@ def _register_pagination_header(self): openapi_cli = flask.cli.AppGroup("openapi", help="OpenAPI commands.") -def _get_spec_dict(): - return current_app.extensions["flask-smorest"]["ext_obj"].spec.to_dict() +def _get_spec_dict(config_prefix): + ext = current_app.extensions["flask-smorest"] + if config_prefix not in ext["apis"]: + if config_prefix == "": + click.echo( + "Error: Your API is using config_prefix. " + "Please provide a --config-prefix option.", + err=True, + ) + else: + click.echo(f"Error: `{config_prefix}` not available. Use one of:", err=True) + for key in ext["apis"].keys(): + click.echo(f" {key}", err=True) + raise click.exceptions.Exit() + + return ext["apis"][config_prefix].spec.to_dict() @openapi_cli.command("print") @click.option("-f", "--format", type=click.Choice(["json", "yaml"]), default="json") -def print_openapi_doc(format): +@click.option("--config-prefix", type=click.STRING, metavar="", default="") +def print_openapi_doc(format, config_prefix): """Print OpenAPI JSON document.""" + config_prefix = normalize_config_prefix(config_prefix) if format == "json": - click.echo(json.dumps(_get_spec_dict(), indent=2)) + click.echo(json.dumps(_get_spec_dict(config_prefix), indent=2)) else: # format == "yaml" if HAS_PYYAML: - click.echo(yaml.dump(_get_spec_dict())) + click.echo(yaml.dump(_get_spec_dict(config_prefix))) else: click.echo( "To use yaml output format, please install PyYAML module", err=True @@ -383,15 +441,27 @@ def print_openapi_doc(format): @openapi_cli.command("write") @click.option("-f", "--format", type=click.Choice(["json", "yaml"]), default="json") +@click.option("--config-prefix", type=click.STRING, metavar="", default="") @click.argument("output_file", type=click.File(mode="w")) -def write_openapi_doc(format, output_file): +def write_openapi_doc(format, output_file, config_prefix): """Write OpenAPI JSON document to a file.""" + config_prefix = normalize_config_prefix(config_prefix) if format == "json": - click.echo(json.dumps(_get_spec_dict(), indent=2), file=output_file) + click.echo( + json.dumps(_get_spec_dict(config_prefix), indent=2), + file=output_file, + ) else: # format == "yaml" if HAS_PYYAML: - yaml.dump(_get_spec_dict(), output_file) + yaml.dump(_get_spec_dict(config_prefix), output_file) else: click.echo( "To use yaml output format, please install PyYAML module", err=True ) + + +@openapi_cli.command("list-config-prefixes") +def list_config_prefixes(): + """List available API config prefixes.""" + for prefix in current_app.extensions["flask-smorest"]["apis"].keys(): + click.echo(prefix) 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 @@