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:
Robbe Sneyders
2023-10-17 23:40:13 +02:00
committed by GitHub
parent 17fcad0c30
commit 41c525c52c
13 changed files with 215 additions and 22 deletions

View File

@@ -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,

View File

@@ -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(

View File

@@ -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(

View File

@@ -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)

View File

@@ -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(

View File

@@ -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:

View File

@@ -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()

View 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"

View File

@@ -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"

View 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

View 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

View 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

View 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