mirror of
https://github.com/LukeHagar/connexion.git
synced 2025-12-09 20:37:46 +00:00
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>
This commit is contained in:
@@ -122,6 +122,7 @@ class AbstractApp:
|
|||||||
specification: t.Union[pathlib.Path, str, dict],
|
specification: t.Union[pathlib.Path, str, dict],
|
||||||
*,
|
*,
|
||||||
base_path: t.Optional[str] = None,
|
base_path: t.Optional[str] = None,
|
||||||
|
name: t.Optional[str] = None,
|
||||||
arguments: t.Optional[dict] = None,
|
arguments: t.Optional[dict] = None,
|
||||||
auth_all_paths: t.Optional[bool] = None,
|
auth_all_paths: t.Optional[bool] = None,
|
||||||
jsonifier: t.Optional[Jsonifier] = None,
|
jsonifier: t.Optional[Jsonifier] = None,
|
||||||
@@ -144,6 +145,8 @@ class AbstractApp:
|
|||||||
to file.
|
to file.
|
||||||
:param base_path: Base path to host the API. This overrides the basePath / servers in the
|
:param base_path: Base path to host the API. This overrides the basePath / servers in the
|
||||||
specification.
|
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 arguments: Arguments to substitute the specification using Jinja.
|
||||||
:param auth_all_paths: whether to authenticate not paths not defined in the specification.
|
:param auth_all_paths: whether to authenticate not paths not defined in the specification.
|
||||||
Defaults to False.
|
Defaults to False.
|
||||||
@@ -175,6 +178,7 @@ class AbstractApp:
|
|||||||
return self.middleware.add_api(
|
return self.middleware.add_api(
|
||||||
specification,
|
specification,
|
||||||
base_path=base_path,
|
base_path=base_path,
|
||||||
|
name=name,
|
||||||
arguments=arguments,
|
arguments=arguments,
|
||||||
auth_all_paths=auth_all_paths,
|
auth_all_paths=auth_all_paths,
|
||||||
jsonifier=jsonifier,
|
jsonifier=jsonifier,
|
||||||
|
|||||||
@@ -93,14 +93,18 @@ class AsyncMiddlewareApp(RoutedMiddleware[AsyncApi]):
|
|||||||
api_cls = AsyncApi
|
api_cls = AsyncApi
|
||||||
|
|
||||||
def __init__(self) -> None:
|
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.operations: t.Dict[str, AsyncOperation] = {}
|
||||||
self.router = Router()
|
self.router = Router()
|
||||||
super().__init__(self.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)
|
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
|
return api
|
||||||
|
|
||||||
def add_url_rule(
|
def add_url_rule(
|
||||||
|
|||||||
@@ -155,9 +155,14 @@ class FlaskMiddlewareApp(SpecMiddleware):
|
|||||||
(response.body, response.status_code, response.headers)
|
(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)
|
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
|
return api
|
||||||
|
|
||||||
def add_url_rule(
|
def add_url_rule(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import abc
|
|||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import typing as t
|
import typing as t
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
@@ -182,7 +183,7 @@ class RoutedAPI(AbstractSpecAPI, t.Generic[OP]):
|
|||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(specification, *args, **kwargs)
|
super().__init__(specification, *args, **kwargs)
|
||||||
self.next_app = next_app
|
self.next_app = next_app
|
||||||
self.operations: t.MutableMapping[str, OP] = {}
|
self.operations: t.MutableMapping[t.Optional[str], OP] = {}
|
||||||
|
|
||||||
def add_paths(self) -> None:
|
def add_paths(self) -> None:
|
||||||
paths = self.specification.get("paths", {})
|
paths = self.specification.get("paths", {})
|
||||||
@@ -232,11 +233,11 @@ class RoutedMiddleware(SpecMiddleware, t.Generic[API]):
|
|||||||
|
|
||||||
def __init__(self, app: ASGIApp) -> None:
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
self.app = app
|
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:
|
def add_api(self, specification: t.Union[pathlib.Path, str, dict], **kwargs) -> API:
|
||||||
api = self.api_cls(specification, next_app=self.app, **kwargs)
|
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
|
return api
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
@@ -254,19 +255,19 @@ class RoutedMiddleware(SpecMiddleware, t.Generic[API]):
|
|||||||
)
|
)
|
||||||
api_base_path = connexion_context.get("api_base_path")
|
api_base_path = connexion_context.get("api_base_path")
|
||||||
if api_base_path is not None and api_base_path in self.apis:
|
if api_base_path is not None and api_base_path in self.apis:
|
||||||
api = self.apis[api_base_path]
|
for api in self.apis[api_base_path]:
|
||||||
operation_id = connexion_context.get("operation_id")
|
operation_id = connexion_context.get("operation_id")
|
||||||
try:
|
try:
|
||||||
operation = api.operations[operation_id]
|
operation = api.operations[operation_id]
|
||||||
except KeyError as e:
|
except KeyError:
|
||||||
if operation_id is None:
|
if operation_id is None:
|
||||||
logger.debug("Skipping operation without id.")
|
logger.debug("Skipping operation without id.")
|
||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
raise MissingOperation("Encountered unknown operation_id.") from e
|
return await operation(scope, receive, send)
|
||||||
else:
|
|
||||||
return await operation(scope, receive, send)
|
raise MissingOperation("Encountered unknown operation_id.")
|
||||||
|
|
||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ class ConnexionMiddleware:
|
|||||||
specification: t.Union[pathlib.Path, str, dict],
|
specification: t.Union[pathlib.Path, str, dict],
|
||||||
*,
|
*,
|
||||||
base_path: t.Optional[str] = None,
|
base_path: t.Optional[str] = None,
|
||||||
|
name: t.Optional[str] = None,
|
||||||
arguments: t.Optional[dict] = None,
|
arguments: t.Optional[dict] = None,
|
||||||
auth_all_paths: t.Optional[bool] = None,
|
auth_all_paths: t.Optional[bool] = None,
|
||||||
jsonifier: t.Optional[Jsonifier] = None,
|
jsonifier: t.Optional[Jsonifier] = None,
|
||||||
@@ -354,6 +355,8 @@ class ConnexionMiddleware:
|
|||||||
to file.
|
to file.
|
||||||
:param base_path: Base path to host the API. This overrides the basePath / servers in the
|
:param base_path: Base path to host the API. This overrides the basePath / servers in the
|
||||||
specification.
|
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 arguments: Arguments to substitute the specification using Jinja.
|
||||||
:param auth_all_paths: whether to authenticate not paths not defined in the specification.
|
:param auth_all_paths: whether to authenticate not paths not defined in the specification.
|
||||||
Defaults to False.
|
Defaults to False.
|
||||||
@@ -410,7 +413,9 @@ class ConnexionMiddleware:
|
|||||||
security_map=security_map,
|
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)
|
self.apis.append(api)
|
||||||
|
|
||||||
def add_error_handler(
|
def add_error_handler(
|
||||||
|
|||||||
@@ -128,6 +128,17 @@ class RoutingMiddleware(SpecMiddleware):
|
|||||||
next_app=self.app,
|
next_app=self.app,
|
||||||
**kwargs,
|
**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)
|
self.router.mount(api.base_path, app=api.router)
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class SecurityAPI(RoutedAPI[SecurityOperation]):
|
|||||||
if auth_all_paths:
|
if auth_all_paths:
|
||||||
self.add_auth_on_not_found()
|
self.add_auth_on_not_found()
|
||||||
else:
|
else:
|
||||||
self.operations: t.MutableMapping[str, SecurityOperation] = {}
|
self.operations: t.MutableMapping[t.Optional[str], SecurityOperation] = {}
|
||||||
|
|
||||||
self.add_paths()
|
self.add_paths()
|
||||||
|
|
||||||
|
|||||||
48
tests/api/test_bootstrap_multiple_spec.py
Normal file
48
tests/api/test_bootstrap_multiple_spec.py
Normal file
@@ -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"
|
||||||
@@ -42,6 +42,11 @@ def json_validation_spec_dir():
|
|||||||
return FIXTURES_FOLDER / "json_validation"
|
return FIXTURES_FOLDER / "json_validation"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def multiple_yaml_same_basepath_dir():
|
||||||
|
return FIXTURES_FOLDER / "multiple_yaml_same_basepath"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def json_datetime_dir():
|
def json_datetime_dir():
|
||||||
return FIXTURES_FOLDER / "datetime_support"
|
return FIXTURES_FOLDER / "datetime_support"
|
||||||
|
|||||||
28
tests/fixtures/multiple_yaml_same_basepath/openapi_bye.yaml
vendored
Normal file
28
tests/fixtures/multiple_yaml_same_basepath/openapi_bye.yaml
vendored
Normal file
@@ -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
|
||||||
28
tests/fixtures/multiple_yaml_same_basepath/openapi_greeting.yaml
vendored
Normal file
28
tests/fixtures/multiple_yaml_same_basepath/openapi_greeting.yaml
vendored
Normal file
@@ -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
|
||||||
29
tests/fixtures/multiple_yaml_same_basepath/swagger_bye.yaml
vendored
Normal file
29
tests/fixtures/multiple_yaml_same_basepath/swagger_bye.yaml
vendored
Normal file
@@ -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
|
||||||
25
tests/fixtures/multiple_yaml_same_basepath/swagger_greeting.yaml
vendored
Normal file
25
tests/fixtures/multiple_yaml_same_basepath/swagger_greeting.yaml
vendored
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user