Skip to content

Commit

Permalink
Support multiple APIs with same base path (#1736)
Browse files Browse the repository at this point in the history
Fixes #1542 
Fixes #1724 

Cherry-picked some commits from #1598.

---------

Co-authored-by: Leonardo Festa <4375330+leonardofesta@users.noreply.github.com>
  • Loading branch information
RobbeSneyders and leonardofesta committed Oct 17, 2023
1 parent 17fcad0 commit 41c525c
Show file tree
Hide file tree
Showing 13 changed files with 215 additions and 22 deletions.
4 changes: 4 additions & 0 deletions connexion/apps/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions connexion/apps/asynchronous.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 7 additions & 2 deletions connexion/apps/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
31 changes: 16 additions & 15 deletions connexion/middleware/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import pathlib
import typing as t
from collections import defaultdict

from starlette.types import ASGIApp, Receive, Scope, Send

Expand Down Expand Up @@ -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", {})
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down
7 changes: 6 additions & 1 deletion connexion/middleware/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions connexion/middleware/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion connexion/middleware/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
48 changes: 48 additions & 0 deletions tests/api/test_bootstrap_multiple_spec.py
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 28 additions & 0 deletions tests/fixtures/multiple_yaml_same_basepath/openapi_bye.yaml
Original file line number Diff line number Diff line change
@@ -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 changes: 28 additions & 0 deletions tests/fixtures/multiple_yaml_same_basepath/openapi_greeting.yaml
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 0 deletions tests/fixtures/multiple_yaml_same_basepath/swagger_bye.yaml
Original file line number Diff line number Diff line change
@@ -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 changes: 25 additions & 0 deletions tests/fixtures/multiple_yaml_same_basepath/swagger_greeting.yaml
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 41c525c

Please sign in to comment.