mirror of
https://github.com/LukeHagar/connexion.git
synced 2025-12-06 04:19:26 +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],
|
||||
*,
|
||||
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 @@ class AbstractApp:
|
||||
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 @@ class AbstractApp:
|
||||
return self.middleware.add_api(
|
||||
specification,
|
||||
base_path=base_path,
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
auth_all_paths=auth_all_paths,
|
||||
jsonifier=jsonifier,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -155,9 +155,14 @@ class FlaskMiddlewareApp(SpecMiddleware):
|
||||
(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(
|
||||
|
||||
@@ -2,6 +2,7 @@ import abc
|
||||
import logging
|
||||
import pathlib
|
||||
import typing as t
|
||||
from collections import defaultdict
|
||||
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
@@ -182,7 +183,7 @@ class RoutedAPI(AbstractSpecAPI, t.Generic[OP]):
|
||||
) -> 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 @@ class RoutedMiddleware(SpecMiddleware, t.Generic[API]):
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@@ -332,6 +332,7 @@ class ConnexionMiddleware:
|
||||
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 @@ class ConnexionMiddleware:
|
||||
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 @@ class ConnexionMiddleware:
|
||||
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(
|
||||
|
||||
@@ -128,6 +128,17 @@ class RoutingMiddleware(SpecMiddleware):
|
||||
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:
|
||||
|
||||
@@ -111,7 +111,7 @@ class SecurityAPI(RoutedAPI[SecurityOperation]):
|
||||
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()
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@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"
|
||||
|
||||
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