From 41c525c52c0142a7d73f4d4cb459a14e550e4792 Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Tue, 17 Oct 2023 23:40:13 +0200 Subject: [PATCH] Support multiple APIs with same base path (#1736) Fixes #1542 Fixes #1724 Cherry-picked some commits from #1598. --------- Co-authored-by: Leonardo Festa <4375330+leonardofesta@users.noreply.github.com> --- connexion/apps/abstract.py | 4 ++ connexion/apps/asynchronous.py | 10 ++-- connexion/apps/flask.py | 9 +++- connexion/middleware/abstract.py | 31 ++++++------ connexion/middleware/main.py | 7 ++- connexion/middleware/routing.py | 11 +++++ connexion/middleware/security.py | 2 +- tests/api/test_bootstrap_multiple_spec.py | 48 +++++++++++++++++++ tests/conftest.py | 5 ++ .../openapi_bye.yaml | 28 +++++++++++ .../openapi_greeting.yaml | 28 +++++++++++ .../swagger_bye.yaml | 29 +++++++++++ .../swagger_greeting.yaml | 25 ++++++++++ 13 files changed, 215 insertions(+), 22 deletions(-) create mode 100644 tests/api/test_bootstrap_multiple_spec.py create mode 100644 tests/fixtures/multiple_yaml_same_basepath/openapi_bye.yaml create mode 100644 tests/fixtures/multiple_yaml_same_basepath/openapi_greeting.yaml create mode 100644 tests/fixtures/multiple_yaml_same_basepath/swagger_bye.yaml create mode 100644 tests/fixtures/multiple_yaml_same_basepath/swagger_greeting.yaml diff --git a/connexion/apps/abstract.py b/connexion/apps/abstract.py index 63bcf842a..f70767984 100644 --- a/connexion/apps/abstract.py +++ b/connexion/apps/abstract.py @@ -122,6 +122,7 @@ def add_api( specification: t.Union[pathlib.Path, str, dict], *, base_path: t.Optional[str] = None, + name: t.Optional[str] = None, arguments: t.Optional[dict] = None, auth_all_paths: t.Optional[bool] = None, jsonifier: t.Optional[Jsonifier] = None, @@ -144,6 +145,8 @@ def add_api( to file. :param base_path: Base path to host the API. This overrides the basePath / servers in the specification. + :param name: Name to register the API with. If no name is passed, the base_path is used + as name instead. :param arguments: Arguments to substitute the specification using Jinja. :param auth_all_paths: whether to authenticate not paths not defined in the specification. Defaults to False. @@ -175,6 +178,7 @@ def add_api( return self.middleware.add_api( specification, base_path=base_path, + name=name, arguments=arguments, auth_all_paths=auth_all_paths, jsonifier=jsonifier, diff --git a/connexion/apps/asynchronous.py b/connexion/apps/asynchronous.py index 1d0e3fc96..9974381fd 100644 --- a/connexion/apps/asynchronous.py +++ b/connexion/apps/asynchronous.py @@ -93,14 +93,18 @@ class AsyncMiddlewareApp(RoutedMiddleware[AsyncApi]): api_cls = AsyncApi def __init__(self) -> None: - self.apis: t.Dict[str, AsyncApi] = {} + self.apis: t.Dict[str, t.List[AsyncApi]] = {} self.operations: t.Dict[str, AsyncOperation] = {} self.router = Router() super().__init__(self.router) - def add_api(self, *args, **kwargs): + def add_api(self, *args, name: str = None, **kwargs): api = super().add_api(*args, **kwargs) - self.router.mount(api.base_path, api.router) + + if name is not None: + self.router.mount(api.base_path, api.router, name=name) + else: + self.router.mount(api.base_path, api.router) return api def add_url_rule( diff --git a/connexion/apps/flask.py b/connexion/apps/flask.py index 2121348b4..1bb1e229b 100644 --- a/connexion/apps/flask.py +++ b/connexion/apps/flask.py @@ -155,9 +155,14 @@ def common_error_handler(self, exception: Exception) -> FlaskResponse: (response.body, response.status_code, response.headers) ) - def add_api(self, specification, **kwargs): + def add_api(self, specification, *, name: str = None, **kwargs): api = FlaskApi(specification, **kwargs) - self.app.register_blueprint(api.blueprint) + + if name is not None: + self.app.register_blueprint(api.blueprint, name=name) + else: + self.app.register_blueprint(api.blueprint) + return api def add_url_rule( diff --git a/connexion/middleware/abstract.py b/connexion/middleware/abstract.py index 781d5329b..9be98fe6e 100644 --- a/connexion/middleware/abstract.py +++ b/connexion/middleware/abstract.py @@ -2,6 +2,7 @@ import logging import pathlib import typing as t +from collections import defaultdict from starlette.types import ASGIApp, Receive, Scope, Send @@ -182,7 +183,7 @@ def __init__( ) -> None: super().__init__(specification, *args, **kwargs) self.next_app = next_app - self.operations: t.MutableMapping[str, OP] = {} + self.operations: t.MutableMapping[t.Optional[str], OP] = {} def add_paths(self) -> None: paths = self.specification.get("paths", {}) @@ -232,11 +233,11 @@ class RoutedMiddleware(SpecMiddleware, t.Generic[API]): def __init__(self, app: ASGIApp) -> None: self.app = app - self.apis: t.Dict[str, API] = {} + self.apis: t.Dict[str, t.List[API]] = defaultdict(list) def add_api(self, specification: t.Union[pathlib.Path, str, dict], **kwargs) -> API: api = self.api_cls(specification, next_app=self.app, **kwargs) - self.apis[api.base_path] = api + self.apis[api.base_path].append(api) return api async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: @@ -254,19 +255,19 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ) api_base_path = connexion_context.get("api_base_path") if api_base_path is not None and api_base_path in self.apis: - api = self.apis[api_base_path] - operation_id = connexion_context.get("operation_id") - try: - operation = api.operations[operation_id] - except KeyError as e: - if operation_id is None: - logger.debug("Skipping operation without id.") - await self.app(scope, receive, send) - return + for api in self.apis[api_base_path]: + operation_id = connexion_context.get("operation_id") + try: + operation = api.operations[operation_id] + except KeyError: + if operation_id is None: + logger.debug("Skipping operation without id.") + await self.app(scope, receive, send) + return else: - raise MissingOperation("Encountered unknown operation_id.") from e - else: - return await operation(scope, receive, send) + return await operation(scope, receive, send) + + raise MissingOperation("Encountered unknown operation_id.") await self.app(scope, receive, send) diff --git a/connexion/middleware/main.py b/connexion/middleware/main.py index 1d775f817..ded10d925 100644 --- a/connexion/middleware/main.py +++ b/connexion/middleware/main.py @@ -332,6 +332,7 @@ def add_api( specification: t.Union[pathlib.Path, str, dict], *, base_path: t.Optional[str] = None, + name: t.Optional[str] = None, arguments: t.Optional[dict] = None, auth_all_paths: t.Optional[bool] = None, jsonifier: t.Optional[Jsonifier] = None, @@ -354,6 +355,8 @@ def add_api( to file. :param base_path: Base path to host the API. This overrides the basePath / servers in the specification. + :param name: Name to register the API with. If no name is passed, the base_path is used + as name instead. :param arguments: Arguments to substitute the specification using Jinja. :param auth_all_paths: whether to authenticate not paths not defined in the specification. Defaults to False. @@ -410,7 +413,9 @@ def add_api( security_map=security_map, ) - api = API(specification, base_path=base_path, **options.__dict__, **kwargs) + api = API( + specification, base_path=base_path, name=name, **options.__dict__, **kwargs + ) self.apis.append(api) def add_error_handler( diff --git a/connexion/middleware/routing.py b/connexion/middleware/routing.py index 065fb7cf4..bb3221b31 100644 --- a/connexion/middleware/routing.py +++ b/connexion/middleware/routing.py @@ -128,6 +128,17 @@ def add_api( next_app=self.app, **kwargs, ) + + # If an API with the same base_path was already registered, chain the new API as its + # default. This way, if no matching route is found on the first API, the request is + # forwarded to the new API. + for route in self.router.routes: + if ( + isinstance(route, starlette.routing.Mount) + and route.path == api.base_path + ): + route.app.default = api.router + self.router.mount(api.base_path, app=api.router) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: diff --git a/connexion/middleware/security.py b/connexion/middleware/security.py index 136dfecea..6c4d9010f 100644 --- a/connexion/middleware/security.py +++ b/connexion/middleware/security.py @@ -111,7 +111,7 @@ def __init__( if auth_all_paths: self.add_auth_on_not_found() else: - self.operations: t.MutableMapping[str, SecurityOperation] = {} + self.operations: t.MutableMapping[t.Optional[str], SecurityOperation] = {} self.add_paths() diff --git a/tests/api/test_bootstrap_multiple_spec.py b/tests/api/test_bootstrap_multiple_spec.py new file mode 100644 index 000000000..9bc299b2c --- /dev/null +++ b/tests/api/test_bootstrap_multiple_spec.py @@ -0,0 +1,48 @@ +import json + +import pytest + +from conftest import TEST_FOLDER + +SPECS = [ + pytest.param( + [ + {"specification": "swagger_greeting.yaml", "name": "greeting"}, + {"specification": "swagger_bye.yaml", "name": "bye"}, + ], + id="swagger", + ), + pytest.param( + [ + {"specification": "openapi_greeting.yaml", "name": "greeting"}, + {"specification": "openapi_bye.yaml", "name": "bye"}, + ], + id="openapi", + ), +] + + +@pytest.mark.parametrize("specs", SPECS) +def test_app_with_multiple_definition( + multiple_yaml_same_basepath_dir, specs, app_class +): + app = app_class( + __name__, + specification_dir=".." + / multiple_yaml_same_basepath_dir.relative_to(TEST_FOLDER), + ) + + for spec in specs: + print(spec) + app.add_api(**spec) + + app_client = app.test_client() + + response = app_client.post("/v1.0/greeting/Igor") + assert response.status_code == 200 + print(response.text) + assert response.json()["greeting"] == "Hello Igor" + + response = app_client.get("/v1.0/bye/Musti") + assert response.status_code == 200 + assert response.text == "Goodbye Musti" diff --git a/tests/conftest.py b/tests/conftest.py index f5dab724b..762121b76 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,11 @@ def json_validation_spec_dir(): return FIXTURES_FOLDER / "json_validation" +@pytest.fixture +def multiple_yaml_same_basepath_dir(): + return FIXTURES_FOLDER / "multiple_yaml_same_basepath" + + @pytest.fixture(scope="session") def json_datetime_dir(): return FIXTURES_FOLDER / "datetime_support" diff --git a/tests/fixtures/multiple_yaml_same_basepath/openapi_bye.yaml b/tests/fixtures/multiple_yaml_same_basepath/openapi_bye.yaml new file mode 100644 index 000000000..1970a9b15 --- /dev/null +++ b/tests/fixtures/multiple_yaml_same_basepath/openapi_bye.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + '/bye/{name}': + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.hello.get_bye + responses: + '200': + description: goodbye response + content: + text/plain: + schema: + type: string + default: + description: unexpected error + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + schema: + type: string +servers: + - url: /v1.0 diff --git a/tests/fixtures/multiple_yaml_same_basepath/openapi_greeting.yaml b/tests/fixtures/multiple_yaml_same_basepath/openapi_greeting.yaml new file mode 100644 index 000000000..eb9d57597 --- /dev/null +++ b/tests/fixtures/multiple_yaml_same_basepath/openapi_greeting.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + '/greeting/{name}': + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: fakeapi.hello.post_greeting + responses: + '200': + description: greeting response + content: + 'application/json': + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + schema: + type: string + + +servers: + - url: /v1.0 diff --git a/tests/fixtures/multiple_yaml_same_basepath/swagger_bye.yaml b/tests/fixtures/multiple_yaml_same_basepath/swagger_bye.yaml new file mode 100644 index 000000000..2905e126c --- /dev/null +++ b/tests/fixtures/multiple_yaml_same_basepath/swagger_bye.yaml @@ -0,0 +1,29 @@ +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /bye/{name}: + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.hello.get_bye + produces: + - text/plain + responses: + '200': + description: goodbye response + schema: + type: string + default: + description: "unexpected error" + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + type: string diff --git a/tests/fixtures/multiple_yaml_same_basepath/swagger_greeting.yaml b/tests/fixtures/multiple_yaml_same_basepath/swagger_greeting.yaml new file mode 100644 index 000000000..f3246b530 --- /dev/null +++ b/tests/fixtures/multiple_yaml_same_basepath/swagger_greeting.yaml @@ -0,0 +1,25 @@ +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /greeting/{name}: + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: fakeapi.hello.post_greeting + responses: + 200: + description: greeting response + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + type: string \ No newline at end of file