From 8c67c2cc2a49f1bf77e5a9d5ef40acac3d4928d8 Mon Sep 17 00:00:00 2001 From: dcaayushd Date: Mon, 22 Jun 2026 19:01:50 +0545 Subject: [PATCH] Add host-owned ASGI route registration --- examples/a2a/src/main.py | 7 +- examples/http-adapters/README.md | 12 ++- examples/http-adapters/pyproject.toml | 2 +- .../http-adapters/src/fastapi_non_managed.py | 16 ++-- examples/http-adapters/src/starlette_echo.py | 5 +- examples/mcp-server/src/main.py | 4 +- packages/apps/README.md | 31 +++++++ .../apps/src/microsoft_teams/apps/__init__.py | 4 +- packages/apps/src/microsoft_teams/apps/app.py | 78 +++++++++++++++--- .../src/microsoft_teams/apps/http/__init__.py | 5 +- .../src/microsoft_teams/apps/http/adapter.py | 10 ++- .../microsoft_teams/apps/http/http_server.py | 17 +++- .../apps/http}/starlette_adapter.py | 56 ++++++------- packages/apps/tests/test_app.py | 81 +++++++++++++++++++ packages/apps/tests/test_http_server.py | 74 ++++++++++++++++- 15 files changed, 329 insertions(+), 73 deletions(-) rename {examples/http-adapters/src => packages/apps/src/microsoft_teams/apps/http}/starlette_adapter.py (62%) diff --git a/examples/a2a/src/main.py b/examples/a2a/src/main.py index e1d0599fc..f9eaf75e6 100644 --- a/examples/a2a/src/main.py +++ b/examples/a2a/src/main.py @@ -80,6 +80,11 @@ async def handle_message(ctx: ActivityContext[MessageActivity]) -> None: await ctx.reply(reply) +# Register Teams routes synchronously so the host-owned FastAPI route table is +# complete before the server starts. +app.register_routes() + + async def main() -> None: # Build this bot's own AgentCard — equivalent to AgentCardFactory.Build(config) in C#. agent_card = build_agent_card(config) @@ -89,7 +94,7 @@ async def main() -> None: a2a_starlette = make_a2a_app(teams_app=app, agent=bot_agent, config=config, agent_card=agent_card) fastapi_app.mount("/a2a", a2a_starlette.build()) - await app.initialize() + await app.start_plugins() port = int(getenv("PORT", "3978")) server = uvicorn.Server(uvicorn.Config(fastapi_app, host="0.0.0.0", port=port, log_level="info")) diff --git a/examples/http-adapters/README.md b/examples/http-adapters/README.md index 465af849f..e8258366c 100644 --- a/examples/http-adapters/README.md +++ b/examples/http-adapters/README.md @@ -1,12 +1,12 @@ # HTTP Adapters Examples -Examples showing how to use custom `HttpServerAdapter` implementations and non-managed server patterns with the Teams Python SDK. +Examples showing how to use shipped `HttpServerAdapter` implementations and non-managed server patterns with the Teams Python SDK. ## Examples ### 1. Starlette Adapter (`starlette_echo.py`) -A custom `HttpServerAdapter` implementation for [Starlette](https://www.starlette.io/). Demonstrates how to write an adapter for any ASGI framework. +The shipped `StarletteAdapter` implementation for [Starlette](https://www.starlette.io/). **Pattern**: Custom adapter, SDK-managed server lifecycle (`app.start()`) @@ -18,7 +18,7 @@ python src/starlette_echo.py Use your own FastAPI app with your own routes, and let the SDK register `/api/messages` on it. You manage the server lifecycle yourself. -**Pattern**: Default `FastAPIAdapter` with user-provided FastAPI instance, user-managed server (`app.initialize()` + your own `uvicorn.Server`) +**Pattern**: Default `FastAPIAdapter` with user-provided FastAPI instance, user-managed server (`app.register_routes()` + `await app.start_plugins()` + your own `uvicorn.Server`) ```bash python src/fastapi_non_managed.py @@ -30,10 +30,14 @@ python src/fastapi_non_managed.py | | Managed | Non-Managed | |---|---|---| -| **Entry point** | `app.start(port)` | `app.initialize()` + start server yourself | +| **Entry point** | `app.start(port)` | `app.register_routes()` + `await app.start_plugins()` + start server yourself | | **Who starts the server** | The SDK (via adapter) | You | | **When to use** | New apps, simple setup | Existing apps, custom server config | +`app.register_routes()` is synchronous and registers the Teams messaging route +without running async plugin initialization. Run `await app.start_plugins()` from +your host's startup hook when the event loop is available. + ### Writing a Custom Adapter Implement the `HttpServerAdapter` protocol: diff --git a/examples/http-adapters/pyproject.toml b/examples/http-adapters/pyproject.toml index 4aa70c81f..3971c1ef5 100644 --- a/examples/http-adapters/pyproject.toml +++ b/examples/http-adapters/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "http-adapters" version = "0.1.0" -description = "Examples showing custom HttpServerAdapter and non-managed server patterns" +description = "Examples showing HttpServerAdapter and non-managed server patterns" readme = "README.md" requires-python = ">=3.11,<4.0" dependencies = [ diff --git a/examples/http-adapters/src/fastapi_non_managed.py b/examples/http-adapters/src/fastapi_non_managed.py index 9b3924008..0bfdcf1a1 100644 --- a/examples/http-adapters/src/fastapi_non_managed.py +++ b/examples/http-adapters/src/fastapi_non_managed.py @@ -8,8 +8,8 @@ # Teams echo bot where YOU manage the server lifecycle. # # This demonstrates the "non-managed" pattern — you create your own FastAPI app -# with your own routes, wrap it in a FastAPIAdapter, call app.initialize() to -# register the Teams routes, then start uvicorn yourself. +# with your own routes, wrap it in a FastAPIAdapter, register Teams routes +# synchronously, then start uvicorn yourself. # # This is ideal when: # - You have an existing FastAPI app and want to add Teams bot support @@ -81,12 +81,16 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): await ctx.send(f"[FastAPI non-managed] You said: '{ctx.activity.text}'") +# 6. Register routes synchronously — registers /api/messages on our FastAPI app. +# Does NOT start a server and does NOT run async plugin init hooks. +app.register_routes() + + async def main(): port = int(os.getenv("PORT", "3978")) - # 5. Initialize only — registers /api/messages on our FastAPI app - # Does NOT start a server - await app.initialize() + # 7. Initialize async plugins during host startup. + await app.start_plugins() print(f"Starting server on http://localhost:{port}") print(" GET / — Homepage") @@ -94,7 +98,7 @@ async def main(): print(" GET /api/users — Users API") print(" POST /api/messages — Teams bot endpoint (added by SDK)") - # 6. Start your own uvicorn server + # 8. Start your own uvicorn server config = uvicorn.Config(app=my_fastapi, host="0.0.0.0", port=port, log_level="info") server = uvicorn.Server(config) await server.serve() diff --git a/examples/http-adapters/src/starlette_echo.py b/examples/http-adapters/src/starlette_echo.py index d816f4a63..a226fc975 100644 --- a/examples/http-adapters/src/starlette_echo.py +++ b/examples/http-adapters/src/starlette_echo.py @@ -5,7 +5,7 @@ # Starlette Echo Bot # ================== -# Teams echo bot using a custom StarletteAdapter. +# Teams echo bot using the shipped StarletteAdapter. # # This demonstrates the "managed" pattern — the SDK manages the server lifecycle # via app.start(). The adapter creates its own Starlette app and uvicorn server. @@ -17,10 +17,9 @@ import re from microsoft_teams.api import MessageActivity -from microsoft_teams.apps import ActivityContext, App +from microsoft_teams.apps import ActivityContext, App, StarletteAdapter from starlette.requests import Request from starlette.responses import JSONResponse -from starlette_adapter import StarletteAdapter # 1. Create adapter adapter = StarletteAdapter() diff --git a/examples/mcp-server/src/main.py b/examples/mcp-server/src/main.py index 6583c2b25..0ae872521 100644 --- a/examples/mcp-server/src/main.py +++ b/examples/mcp-server/src/main.py @@ -16,10 +16,10 @@ async def main() -> None: - # app.initialize() must be called before mounting the MCP app so that + # app.register_routes() must be called before mounting the MCP app so that # /api/messages is registered first — FastAPI routes take priority over # mounted sub-applications, and the MCP mount uses a catch-all path (/). - await app.initialize() + app.register_routes() adapter = app.server.adapter if not isinstance(adapter, FastAPIAdapter): diff --git a/packages/apps/README.md b/packages/apps/README.md index 9a21ccd23..30cc22fa6 100644 --- a/packages/apps/README.md +++ b/packages/apps/README.md @@ -39,6 +39,37 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): await app.start() ``` +## Host-Owned ASGI Lifecycle + +If another ASGI app owns the server lifecycle, register Teams routes synchronously +and initialize plugins during startup: + +```python +from fastapi import FastAPI +from microsoft_teams.apps import App, FastAPIAdapter + +asgi_app = FastAPI() +teams = App(http_server_adapter=FastAPIAdapter(app=asgi_app)) + +routes = teams.register_routes() # registers POST /api/messages, does not start a server + + +async def startup(): + # Call this from your host's ASGI startup hook. + await teams.start_plugins() +``` + +For Starlette hosts, use the shipped `StarletteAdapter`: + +```python +from microsoft_teams.apps import App, StarletteAdapter +from starlette.applications import Starlette + +asgi_app = Starlette() +teams = App(http_server_adapter=StarletteAdapter(app=asgi_app)) +teams.register_routes() +``` + ## OAuth and Graph Integration ```python diff --git a/packages/apps/src/microsoft_teams/apps/__init__.py b/packages/apps/src/microsoft_teams/apps/__init__.py index 54ad5b922..4812ecce0 100644 --- a/packages/apps/src/microsoft_teams/apps/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/__init__.py @@ -10,7 +10,7 @@ from .auth import * # noqa: F403 from .contexts import * # noqa: F403 from .events import * # noqa: F401, F403 -from .http import FastAPIAdapter, HttpServer, HttpServerAdapter +from .http import FastAPIAdapter, HttpRoute, HttpServer, HttpServerAdapter, StarletteAdapter from .http_stream import HttpStream from .options import AppOptions from .plugins import * # noqa: F401, F403 @@ -26,6 +26,8 @@ "HttpServer", "HttpServerAdapter", "FastAPIAdapter", + "StarletteAdapter", + "HttpRoute", "HttpStream", "ActivityContext", "to_threaded_conversation_id", diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index c8e93be73..68b6bdb1b 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -52,7 +52,7 @@ get_event_type_from_signature, is_registered_event, ) -from .http import FastAPIAdapter, HttpServer +from .http import FastAPIAdapter, HttpRoute, HttpServer from .http.adapter import HttpRequest, HttpResponse from .options import AppOptions, InternalAppOptions from .plugins import PluginBase, PluginStartEvent @@ -123,6 +123,8 @@ def __init__(self, **options: Unpack[AppOptions]): self.container.set_provider("HttpServer", providers.Object(self.server)) self._port: Optional[int] = None + self._routes_registered = False + self._plugins_initialized = False self._initialized = False # initialize ActivitySender for sending activities @@ -187,6 +189,36 @@ def id(self) -> Optional[str]: return None return self.credentials.client_id + def register_routes(self) -> list[HttpRoute]: + """ + Register the Teams messaging endpoint synchronously. + + This wires the app activity dispatcher, configures inbound auth, and + registers the messaging route on the configured HTTP adapter. It does + not run plugin ``on_init`` hooks and does not start or stop a server. + """ + try: + return self._register_routes() + except Exception as error: + logger.error(f"Failed to register app routes: {error}") + self._events.emit("error", ErrorEvent(error, context={"method": "register_routes"})) + raise + + async def start_plugins(self) -> None: + """ + Run plugin ``on_init`` hooks without registering routes or starting a server. + + This is intended for host-owned ASGI lifecycles that call + :meth:`register_routes` synchronously during route contribution, then + initialize async plugins during application startup. + """ + try: + await self._initialize_plugins() + except Exception as error: + logger.error(f"Failed to initialize plugins: {error}") + self._events.emit("error", ErrorEvent(error, context={"method": "start_plugins"})) + raise + async def initialize(self) -> None: """ Initialize the Teams application without starting the HTTP server. @@ -200,20 +232,11 @@ async def initialize(self) -> None: try: # Initialize plugins first (they may register routes, e.g. BotBuilder's /api/messages) - for plugin in self.plugins: - self._plugin_processor.inject(plugin) - if hasattr(plugin, "on_init") and callable(plugin.on_init): - await plugin.on_init() + await self._initialize_plugins() # Initialize HttpServer (JWT validation + messaging endpoint route) - self.server.on_request = self._process_activity_event - self.server.initialize( - credentials=self.credentials, - skip_auth=self.options.skip_auth, - cloud=self.cloud, - ) + self._register_routes() - self._initialized = True logger.info("Teams app initialized successfully") except Exception as error: @@ -299,7 +322,10 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap """ if not self._initialized: - raise ValueError("app not initialized - call app.initialize() or app.start() first") + raise ValueError( + "app not initialized - call app.initialize(), app.start(), " + "or app.register_routes() followed by app.start_plugins() first" + ) if self.id is None: raise ValueError("app credentials not configured") @@ -593,6 +619,32 @@ async def _stop_plugins(self) -> None: if hasattr(plugin, "on_stop") and callable(plugin.on_stop): await plugin.on_stop() + async def _initialize_plugins(self) -> None: + if self._plugins_initialized: + return + + for plugin in self.plugins: + self._plugin_processor.inject(plugin) + if hasattr(plugin, "on_init") and callable(plugin.on_init): + await plugin.on_init() + + self._plugins_initialized = True + self._refresh_initialized() + + def _register_routes(self) -> list[HttpRoute]: + self.server.on_request = self._process_activity_event + routes = self.server.initialize( + credentials=self.credentials, + skip_auth=self.options.skip_auth, + cloud=self.cloud, + ) + self._routes_registered = True + self._refresh_initialized() + return routes + + def _refresh_initialized(self) -> None: + self._initialized = self._routes_registered and self._plugins_initialized + async def _get_bot_token(self): return await self._token_manager.get_bot_token() diff --git a/packages/apps/src/microsoft_teams/apps/http/__init__.py b/packages/apps/src/microsoft_teams/apps/http/__init__.py index f7751fd14..d50d2261d 100644 --- a/packages/apps/src/microsoft_teams/apps/http/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/http/__init__.py @@ -3,16 +3,19 @@ Licensed under the MIT License. """ -from .adapter import HttpMethod, HttpRequest, HttpResponse, HttpRouteHandler, HttpServerAdapter +from .adapter import HttpMethod, HttpRequest, HttpResponse, HttpRoute, HttpRouteHandler, HttpServerAdapter from .fastapi_adapter import FastAPIAdapter from .http_server import HttpServer +from .starlette_adapter import StarletteAdapter __all__ = [ "HttpMethod", "HttpRequest", "HttpResponse", + "HttpRoute", "HttpRouteHandler", "HttpServer", "HttpServerAdapter", "FastAPIAdapter", + "StarletteAdapter", ] diff --git a/packages/apps/src/microsoft_teams/apps/http/adapter.py b/packages/apps/src/microsoft_teams/apps/http/adapter.py index 8bbb8bd25..c6ba8fda6 100644 --- a/packages/apps/src/microsoft_teams/apps/http/adapter.py +++ b/packages/apps/src/microsoft_teams/apps/http/adapter.py @@ -3,7 +3,7 @@ Licensed under the MIT License. """ -from typing import Dict, Literal, Protocol, TypedDict, runtime_checkable +from typing import Dict, Literal, NamedTuple, Protocol, TypedDict, runtime_checkable HttpMethod = Literal["POST"] @@ -22,6 +22,14 @@ class HttpRouteHandler(Protocol): async def __call__(self, request: HttpRequest) -> HttpResponse: ... +class HttpRoute(NamedTuple): + """Framework-agnostic HTTP route registered by the Teams app.""" + + method: HttpMethod + path: str + handler: HttpRouteHandler + + @runtime_checkable class HttpServerAdapter(Protocol): """Protocol for framework-specific HTTP server adapters. diff --git a/packages/apps/src/microsoft_teams/apps/http/http_server.py b/packages/apps/src/microsoft_teams/apps/http/http_server.py index e11c411a9..d22f86b81 100644 --- a/packages/apps/src/microsoft_teams/apps/http/http_server.py +++ b/packages/apps/src/microsoft_teams/apps/http/http_server.py @@ -15,7 +15,7 @@ from ..auth import TokenValidator from ..events import ActivityEvent, CoreActivity -from .adapter import HttpRequest, HttpResponse, HttpServerAdapter +from .adapter import HttpRequest, HttpResponse, HttpRoute, HttpServerAdapter logger = logging.getLogger(__name__) @@ -47,6 +47,7 @@ def __init__(self, adapter: HttpServerAdapter, messaging_endpoint: str = "/api/m self._skip_auth: bool = False self._cloud: CloudEnvironment = PUBLIC self._initialized: bool = False + self._routes: list[HttpRoute] = [] @property def adapter(self) -> HttpServerAdapter: @@ -58,6 +59,11 @@ def messaging_endpoint(self) -> str: """The URL path for the Teams messaging endpoint.""" return self._messaging_endpoint + @property + def routes(self) -> list[HttpRoute]: + """Routes registered by this server.""" + return list(self._routes) + @property def on_request(self) -> Optional[Callable[[ActivityEvent], Awaitable[InvokeResponse[Any]]]]: """Callback set by App to process activities.""" @@ -72,7 +78,7 @@ def initialize( credentials: Optional[Credentials] = None, skip_auth: bool = False, cloud: Optional[CloudEnvironment] = None, - ) -> None: + ) -> list[HttpRoute]: """ Set up JWT validation and register the messaging endpoint route. @@ -82,7 +88,7 @@ def initialize( cloud: Optional cloud environment for sovereign cloud support. """ if self._initialized: - return + return self.routes self._skip_auth = skip_auth self._cloud = cloud or PUBLIC @@ -107,8 +113,11 @@ def initialize( self._messaging_endpoint, ) - self._adapter.register_route("POST", self._messaging_endpoint, self.handle_request) + route = HttpRoute("POST", self._messaging_endpoint, self.handle_request) + self._adapter.register_route(route.method, route.path, route.handler) + self._routes.append(route) self._initialized = True + return self.routes async def handle_request(self, request: HttpRequest) -> HttpResponse: """Handle incoming activity request. Public so plugins (e.g. BotBuilder) can route through SDK auth.""" diff --git a/examples/http-adapters/src/starlette_adapter.py b/packages/apps/src/microsoft_teams/apps/http/starlette_adapter.py similarity index 62% rename from examples/http-adapters/src/starlette_adapter.py rename to packages/apps/src/microsoft_teams/apps/http/starlette_adapter.py index 0dbab016d..7061808cc 100644 --- a/examples/http-adapters/src/starlette_adapter.py +++ b/packages/apps/src/microsoft_teams/apps/http/starlette_adapter.py @@ -1,45 +1,26 @@ """ Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License. - -Starlette Adapter -================= -A custom HttpServerAdapter implementation for Starlette. - -This shows how to implement the adapter protocol for any ASGI framework. -The adapter translates between the framework's request/response model -and the SDK's pure handler pattern: ({ body, headers }) -> { status, body }. """ -from typing import Optional +import warnings +from typing import Any, Dict, Optional import uvicorn -from microsoft_teams.apps.http.adapter import HttpMethod, HttpRequest, HttpResponse, HttpRouteHandler from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles +from .adapter import HttpMethod, HttpRequest, HttpResponse, HttpRouteHandler -class StarletteAdapter: - """ - HttpServerAdapter implementation wrapping Starlette + uvicorn. - - Usage: - adapter = StarletteAdapter() - app = App(http_server_adapter=adapter) - await app.start(3978) - Or bring your own Starlette instance: - starlette_app = Starlette() - adapter = StarletteAdapter(starlette_app) - app = App(http_server_adapter=adapter) - await app.initialize() # Just registers routes, doesn't start server - """ +class StarletteAdapter: + """HttpServerAdapter implementation wrapping Starlette + uvicorn.""" def __init__(self, app: Optional[Starlette] = None): - self._app = app or Starlette() + self._starlette = app or Starlette() self._is_user_provided = app is not None self._server: Optional[uvicorn.Server] = None self._routes: list[Route] = [] @@ -47,14 +28,16 @@ def __init__(self, app: Optional[Starlette] = None): @property def app(self) -> Starlette: """The underlying Starlette instance.""" - return self._app + return self._starlette def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None: """Register a route handler on the Starlette app.""" + if method != "POST": + raise ValueError(f"Unsupported HTTP method: {method}") async def starlette_handler(request: Request) -> Response: - body = await request.json() - headers = dict(request.headers) + body: Dict[str, Any] = await request.json() + headers: Dict[str, str] = dict(request.headers) http_request = HttpRequest(body=body, headers=headers) result: HttpResponse = await handler(http_request) status = result["status"] @@ -65,13 +48,13 @@ async def starlette_handler(request: Request) -> Response: route = Route(path, starlette_handler, methods=[method]) self._routes.append(route) - self._app.routes.insert(0, route) + self._starlette.routes.insert(0, route) def serve_static(self, path: str, directory: str) -> None: """Mount a static files directory.""" name = path.strip("/").replace("/", "-") or "static" mount = Mount(path, app=StaticFiles(directory=directory, check_dir=True, html=True), name=name) - self._app.routes.append(mount) + self._starlette.routes.append(mount) async def start(self, port: int) -> None: """Start the uvicorn server. Blocks until stopped.""" @@ -81,8 +64,17 @@ async def start(self, port: int) -> None: "Manage the server lifecycle yourself." ) - config = uvicorn.Config(app=self._app, host="0.0.0.0", port=port, log_level="info") - self._server = uvicorn.Server(config) + if self._server and self._server.config.port != port: + warnings.warn( + f"existing uvicorn server configured port {self._server.config.port}, " + f"but start() requested port {port}. Using existing server port.", + stacklevel=2, + ) + + if not self._server: + config = uvicorn.Config(app=self._starlette, host="0.0.0.0", port=port, log_level="info") + self._server = uvicorn.Server(config) + await self._server.serve() async def stop(self) -> None: diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py index a090860f5..bd01501fe 100644 --- a/packages/apps/tests/test_app.py +++ b/packages/apps/tests/test_app.py @@ -796,6 +796,87 @@ async def test_proactive_targeted_with_explicit_recipient_succeeds(self, mock_st class TestAppInitialize: """Test cases for App.initialize() method.""" + def test_register_routes_is_sync_and_does_not_initialize_plugins(self): + """register_routes() wires the messaging route without running async plugin init.""" + + @Plugin(name="RouteRegistrationPlugin", version="1.0", description="test") + class RouteRegistrationPlugin(PluginBase): + def __init__(self): + self.init_called = False + + async def on_init(self): + self.init_called = True + + plugin = RouteRegistrationPlugin() + app = App(client_id="test-id", client_secret="test-secret", skip_auth=True, plugins=[plugin]) + register_route = MagicMock() + app.server.adapter.register_route = register_route # type: ignore[method-assign] + + routes = app.register_routes() + + assert len(routes) == 1 + route = routes[0] + assert route.method == "POST" + assert route.path == "/api/messages" + assert route.handler == app.server.handle_request + register_route.assert_called_once_with("POST", "/api/messages", route.handler) + assert app.server.on_request is not None + assert plugin.init_called is False + assert app._routes_registered is True + assert app._plugins_initialized is False + assert app._initialized is False + + @pytest.mark.asyncio + async def test_start_plugins_after_register_routes_completes_initialization(self): + """Hosts can contribute routes synchronously, then initialize plugins at startup.""" + + @Plugin(name="HostStartupPlugin", version="1.0", description="test") + class HostStartupPlugin(PluginBase): + def __init__(self): + self.init_called = False + + async def on_init(self): + self.init_called = True + + plugin = HostStartupPlugin() + app = App(client_id="test-id", client_secret="test-secret", skip_auth=True, plugins=[plugin]) + register_route = MagicMock() + app.server.adapter.register_route = register_route # type: ignore[method-assign] + + app.register_routes() + await app.start_plugins() + + assert plugin.init_called is True + assert app._routes_registered is True + assert app._plugins_initialized is True + assert app._initialized is True + register_route.assert_called_once() + + @pytest.mark.asyncio + async def test_initialize_after_register_routes_does_not_duplicate_route(self): + """initialize() should only run the async plugin step when routes were already registered.""" + + @Plugin(name="NoDuplicateRoutePlugin", version="1.0", description="test") + class NoDuplicateRoutePlugin(PluginBase): + def __init__(self): + self.init_called = False + + async def on_init(self): + self.init_called = True + + plugin = NoDuplicateRoutePlugin() + app = App(client_id="test-id", client_secret="test-secret", skip_auth=True, plugins=[plugin]) + register_route = MagicMock() + app.server.adapter.register_route = register_route # type: ignore[method-assign] + + routes = app.register_routes() + await app.initialize() + + assert plugin.init_called is True + assert app._initialized is True + assert app.server.routes == routes + register_route.assert_called_once() + @pytest.mark.asyncio async def test_initialize_enables_send(self): """After initialize(), app.send() should work without starting the server.""" diff --git a/packages/apps/tests/test_http_server.py b/packages/apps/tests/test_http_server.py index 62b894136..13e7bb52b 100644 --- a/packages/apps/tests/test_http_server.py +++ b/packages/apps/tests/test_http_server.py @@ -12,7 +12,7 @@ ConfigResponse, InvokeResponse, ) -from microsoft_teams.apps.http import FastAPIAdapter, HttpServer +from microsoft_teams.apps.http import FastAPIAdapter, HttpServer, StarletteAdapter from microsoft_teams.apps.http.adapter import HttpRequest, HttpResponse @@ -41,10 +41,11 @@ def test_init(self, server, mock_adapter): def test_initialize_idempotent(self, server, mock_adapter): """Test that initialize can be called multiple times safely.""" - server.initialize(skip_auth=True) - server.initialize(skip_auth=True) + routes = server.initialize(skip_auth=True) + second_routes = server.initialize(skip_auth=True) # Should only register route once assert mock_adapter.register_route.call_count == 1 + assert second_routes == routes def test_invalid_messaging_endpoint_raises(self, mock_adapter): """Test that invalid messaging endpoint raises ValueError.""" @@ -57,11 +58,14 @@ def test_messaging_endpoint_default(self, server): def test_initialize_registers_route(self, server, mock_adapter): """Test that initialize registers the /api/messages route.""" - server.initialize() + routes = server.initialize() mock_adapter.register_route.assert_called_once() call_args = mock_adapter.register_route.call_args assert call_args[0][0] == "POST" assert call_args[0][1] == "/api/messages" + assert routes == server.routes + assert routes[0].method == "POST" + assert routes[0].path == "/api/messages" def test_initialize_registers_custom_messaging_endpoint(self, mock_adapter): """Test that initialize registers a custom messaging endpoint.""" @@ -347,3 +351,65 @@ def factory(app): with pytest.raises(ValueError, match="server_factory must return"): FastAPIAdapter(server_factory=factory) + + +class TestStarletteAdapter: + """Test cases for StarletteAdapter.""" + + def test_init_creates_starlette_app(self): + """Test StarletteAdapter creates a Starlette app.""" + from starlette.applications import Starlette + + adapter = StarletteAdapter() + assert isinstance(adapter.app, Starlette) + + def test_register_route(self): + """Test route registration on Starlette app.""" + adapter = StarletteAdapter() + + async def handler(request: HttpRequest) -> HttpResponse: + return HttpResponse(status=200, body=None) + + adapter.register_route("POST", "/test", handler) + + routes = [r for r in adapter.app.routes if hasattr(r, "path") and r.path == "/test"] + assert len(routes) == 1 + + def test_registered_route_handles_request(self): + """Test Starlette request/response translation.""" + from starlette.testclient import TestClient + + adapter = StarletteAdapter() + captured: dict[str, object] = {} + + async def handler(request: HttpRequest) -> HttpResponse: + captured["body"] = request["body"] + captured["headers"] = request["headers"] + return HttpResponse(status=202, body={"ok": True}) + + adapter.register_route("POST", "/test", handler) + + response = TestClient(adapter.app).post("/test", json={"type": "message"}, headers={"x-test": "value"}) + + assert response.status_code == 202 + assert response.json() == {"ok": True} + assert captured["body"] == {"type": "message"} + assert cast(dict[str, str], captured["headers"])["x-test"] == "value" + + def test_serve_static(self, tmp_path): + """Test static file mounting.""" + adapter = StarletteAdapter() + adapter.serve_static("/static", str(tmp_path)) + + mounts = [r for r in adapter.app.routes if hasattr(r, "path") and r.path == "/static"] + assert len(mounts) == 1 + + @pytest.mark.asyncio + async def test_start_with_user_provided_app_raises(self): + """StarletteAdapter does not own lifecycle when the app is caller-provided.""" + from starlette.applications import Starlette + + adapter = StarletteAdapter(app=Starlette()) + + with pytest.raises(RuntimeError, match="Manage the server lifecycle yourself"): + await adapter.start(3978)