From e282eb3ab9a5d2d51a26a4eb5eee4907c30de24e Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 9 Mar 2026 23:18:10 -0700 Subject: [PATCH 1/2] Change HttpPlugin to HttpServer Move to HttpServer instead of HttPlugin following the TS PR. --- examples/http-adapters/README.md | 55 ++++ examples/http-adapters/pyproject.toml | 17 ++ .../http-adapters/src/fastapi_non_managed.py | 91 +++++++ .../http-adapters/src/starlette_adapter.py | 91 +++++++ examples/http-adapters/src/starlette_echo.py | 45 ++++ .../src/microsoft_teams/a2a/server/plugin.py | 10 +- .../apps/src/microsoft_teams/apps/__init__.py | 12 +- packages/apps/src/microsoft_teams/apps/app.py | 123 ++++----- .../src/microsoft_teams/apps/auth/__init__.py | 4 +- .../auth/remote_function_jwt_middleware.py | 118 +++++---- .../src/microsoft_teams/apps/http/__init__.py | 18 ++ .../src/microsoft_teams/apps/http/adapter.py | 39 +++ .../apps/http/fastapi_adapter.py | 76 ++++++ .../microsoft_teams/apps/http/http_server.py | 178 +++++++++++++ .../apps/src/microsoft_teams/apps/options.py | 9 + packages/apps/tests/test_app.py | 75 ++---- packages/apps/tests/test_app_events.py | 6 +- packages/apps/tests/test_app_process.py | 4 +- packages/apps/tests/test_http_server.py | 241 ++++++++++++++++++ .../botbuilder/botbuilder_plugin.py | 98 ++++--- .../tests/test_botbuilder_plugin.py | 76 +++--- .../devtools/devtools_plugin.py | 2 - .../mcpplugin/server_plugin.py | 12 +- uv.lock | 24 ++ 24 files changed, 1162 insertions(+), 262 deletions(-) create mode 100644 examples/http-adapters/README.md create mode 100644 examples/http-adapters/pyproject.toml create mode 100644 examples/http-adapters/src/fastapi_non_managed.py create mode 100644 examples/http-adapters/src/starlette_adapter.py create mode 100644 examples/http-adapters/src/starlette_echo.py create mode 100644 packages/apps/src/microsoft_teams/apps/http/__init__.py create mode 100644 packages/apps/src/microsoft_teams/apps/http/adapter.py create mode 100644 packages/apps/src/microsoft_teams/apps/http/fastapi_adapter.py create mode 100644 packages/apps/src/microsoft_teams/apps/http/http_server.py create mode 100644 packages/apps/tests/test_http_server.py diff --git a/examples/http-adapters/README.md b/examples/http-adapters/README.md new file mode 100644 index 00000000..465af849 --- /dev/null +++ b/examples/http-adapters/README.md @@ -0,0 +1,55 @@ +# HTTP Adapters Examples + +Examples showing how to use custom `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. + +**Pattern**: Custom adapter, SDK-managed server lifecycle (`app.start()`) + +```bash +python src/starlette_echo.py +``` + +### 2. Non-Managed FastAPI (`fastapi_non_managed.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`) + +```bash +python src/fastapi_non_managed.py +``` + +## Key Concepts + +### Managed vs Non-Managed + +| | Managed | Non-Managed | +|---|---|---| +| **Entry point** | `app.start(port)` | `app.initialize()` + start server yourself | +| **Who starts the server** | The SDK (via adapter) | You | +| **When to use** | New apps, simple setup | Existing apps, custom server config | + +### Writing a Custom Adapter + +Implement the `HttpServerAdapter` protocol: + +```python +class MyAdapter: + def register_route(self, method, path, handler): ... + def serve_static(self, path, directory): ... + async def start(self, port): ... + async def stop(self): ... +``` + +The handler signature is framework-agnostic: + +```python +async def handler(request: HttpRequest) -> HttpResponse: + # request = { "body": dict, "headers": dict } + # return { "status": int, "body": object } +``` diff --git a/examples/http-adapters/pyproject.toml b/examples/http-adapters/pyproject.toml new file mode 100644 index 00000000..90955fbe --- /dev/null +++ b/examples/http-adapters/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "http-adapters" +version = "0.1.0" +description = "Examples showing custom HttpServerAdapter and non-managed server patterns" +readme = "README.md" +requires-python = ">=3.12,<3.14" +dependencies = [ + "dotenv>=0.9.9", + "microsoft-teams-apps", + "microsoft-teams-api", + "starlette", + "uvicorn", + "httptools", +] + +[tool.uv.sources] +microsoft-teams-apps = { workspace = true } diff --git a/examples/http-adapters/src/fastapi_non_managed.py b/examples/http-adapters/src/fastapi_non_managed.py new file mode 100644 index 00000000..3ac8d206 --- /dev/null +++ b/examples/http-adapters/src/fastapi_non_managed.py @@ -0,0 +1,91 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Non-Managed FastAPI Server +========================== +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. + +This is ideal when: +- You have an existing FastAPI app and want to add Teams bot support +- You need full control over server configuration (TLS, workers, middleware) +- You're deploying to a platform that manages the server (e.g. Azure Functions) + +Run: + python src/fastapi_non_managed.py +""" + +import asyncio +import os + +import uvicorn +from fastapi import FastAPI +from fastapi.responses import HTMLResponse +from microsoft_teams.api import MessageActivity +from microsoft_teams.apps import ActivityContext, App, FastAPIAdapter + +# 1. Create your own FastAPI app with your own routes +my_fastapi = FastAPI(title="My App + Teams Bot") + + +@my_fastapi.get("/health") +async def health(): + return {"status": "healthy"} + + +@my_fastapi.get("/api/users") +async def users(): + return {"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]} + + +@my_fastapi.get("/") +async def homepage(): + return HTMLResponse(""" +

FastAPI + Teams Bot

+

Your FastAPI server is running with a Teams bot!

+ + """) + + +# 2. Create a FastAPIAdapter wrapping your existing FastAPI app +adapter = FastAPIAdapter(app=my_fastapi) + +# 3. Create the Teams app with the adapter +app = App(http_server_adapter=adapter) + + +# 4. Handle incoming messages +@app.on_message +async def handle_message(ctx: ActivityContext[MessageActivity]): + await ctx.send(f"[FastAPI non-managed] You said: '{ctx.activity.text}'") + + +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() + + print(f"Starting server on http://localhost:{port}") + print(" GET / — Homepage") + print(" GET /health — Health check") + print(" GET /api/users — Users API") + print(" POST /api/messages — Teams bot endpoint (added by SDK)") + + # 6. 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() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/http-adapters/src/starlette_adapter.py b/examples/http-adapters/src/starlette_adapter.py new file mode 100644 index 00000000..0dbab016 --- /dev/null +++ b/examples/http-adapters/src/starlette_adapter.py @@ -0,0 +1,91 @@ +""" +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 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 + + +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 + """ + + def __init__(self, app: Optional[Starlette] = None): + self._app = app or Starlette() + self._is_user_provided = app is not None + self._server: Optional[uvicorn.Server] = None + self._routes: list[Route] = [] + + @property + def app(self) -> Starlette: + """The underlying Starlette instance.""" + return self._app + + def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None: + """Register a route handler on the Starlette app.""" + + async def starlette_handler(request: Request) -> Response: + body = await request.json() + headers = dict(request.headers) + http_request = HttpRequest(body=body, headers=headers) + result: HttpResponse = await handler(http_request) + status = result["status"] + resp_body = result.get("body") + if resp_body is not None: + return JSONResponse(content=resp_body, status_code=status) + return Response(status_code=status) + + route = Route(path, starlette_handler, methods=[method]) + self._routes.append(route) + self._app.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) + + async def start(self, port: int) -> None: + """Start the uvicorn server. Blocks until stopped.""" + if self._is_user_provided: + raise RuntimeError( + "Cannot call start() when a Starlette instance was provided by user. " + "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) + await self._server.serve() + + async def stop(self) -> None: + """Signal the server to stop.""" + if self._server: + self._server.should_exit = True diff --git a/examples/http-adapters/src/starlette_echo.py b/examples/http-adapters/src/starlette_echo.py new file mode 100644 index 00000000..8b0e3960 --- /dev/null +++ b/examples/http-adapters/src/starlette_echo.py @@ -0,0 +1,45 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Starlette Echo Bot +================== +Teams echo bot using a custom 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. + +Run: + python src/starlette_echo.py +""" + +import asyncio + +from microsoft_teams.api import MessageActivity +from microsoft_teams.apps import ActivityContext, App +from starlette_adapter import StarletteAdapter + +# 1. Create adapter +adapter = StarletteAdapter() + + +# 2. Add custom routes directly on the Starlette instance +@adapter.app.route("/health") +async def health(request): + from starlette.responses import JSONResponse + + return JSONResponse({"status": "healthy"}) + + +# 3. Create the Teams app with the adapter +app = App(http_server_adapter=adapter) + + +# 4. Handle incoming messages +@app.on_message +async def handle_message(ctx: ActivityContext[MessageActivity]): + await ctx.send(f"[Starlette] You said: '{ctx.activity.text}'") + + +if __name__ == "__main__": + asyncio.run(app.start()) diff --git a/packages/a2aprotocol/src/microsoft_teams/a2a/server/plugin.py b/packages/a2aprotocol/src/microsoft_teams/a2a/server/plugin.py index 5c106bd1..47f71de8 100644 --- a/packages/a2aprotocol/src/microsoft_teams/a2a/server/plugin.py +++ b/packages/a2aprotocol/src/microsoft_teams/a2a/server/plugin.py @@ -9,7 +9,8 @@ from microsoft_teams.apps import ( DependencyMetadata, EventMetadata, - HttpPlugin, + FastAPIAdapter, + HttpServer, LoggerDependencyOptions, Plugin, PluginBase, @@ -30,7 +31,7 @@ @Plugin(name="a2a", version="0.3.7", description="A2A Server Plugin") class A2APlugin(PluginBase): logger: Annotated[Logger, LoggerDependencyOptions()] - http: Annotated[HttpPlugin, DependencyMetadata()] + server: Annotated[HttpServer, DependencyMetadata()] emit: Annotated[Callable[[str, A2AMessageEvent], Awaitable[None]], EventMetadata(name="custom")] @@ -76,7 +77,10 @@ async def on_init(self) -> None: self.logger.info(f"A2A agent set up at {self.agent_card_path}") self.logger.info(f"A2A agent listening at {self.path}") - self.http.app.mount(self.path, self.app) + adapter = self.server.adapter + if not isinstance(adapter, FastAPIAdapter): + raise RuntimeError("A2APlugin requires FastAPIAdapter. Custom adapters are not supported.") + adapter.app.mount(self.path, self.app) def _setup_executor(self) -> AgentExecutor: return CustomAgentExecutor(self.emit) diff --git a/packages/apps/src/microsoft_teams/apps/__init__.py b/packages/apps/src/microsoft_teams/apps/__init__.py index 2aa32332..265126f1 100644 --- a/packages/apps/src/microsoft_teams/apps/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/__init__.py @@ -8,14 +8,22 @@ from .auth import * # noqa: F403 from .contexts import * # noqa: F403 from .events import * # noqa: F401, F403 -from .http_plugin import HttpPlugin +from .http import FastAPIAdapter, HttpServer, HttpServerAdapter from .http_stream import HttpStream from .options import AppOptions from .plugins import * # noqa: F401, F403 from .routing import ActivityContext # Combine all exports from submodules -__all__: list[str] = ["App", "AppOptions", "HttpPlugin", "HttpStream", "ActivityContext"] +__all__: list[str] = [ + "App", + "AppOptions", + "HttpServer", + "HttpServerAdapter", + "FastAPIAdapter", + "HttpStream", + "ActivityContext", +] __all__.extend(auth.__all__) __all__.extend(events.__all__) __all__.extend(plugins.__all__) diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index f72aceee..6e55f35b 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -11,7 +11,6 @@ from dependency_injector import providers from dotenv import find_dotenv, load_dotenv -from fastapi import Request from microsoft_teams.api import ( Account, ActivityBase, @@ -35,7 +34,7 @@ from .app_plugins import PluginProcessor from .app_process import ActivityProcessor from .auth import TokenValidator -from .auth.remote_function_jwt_middleware import remote_function_jwt_validation +from .auth.remote_function_jwt_middleware import validate_remote_function_request from .container import Container from .contexts.function_context import FunctionContext from .events import ( @@ -46,9 +45,10 @@ get_event_type_from_signature, is_registered_event, ) -from .http_plugin import HttpPlugin +from .http import FastAPIAdapter, HttpServer +from .http.adapter import HttpRequest, HttpResponse from .options import AppOptions, InternalAppOptions -from .plugins import PluginBase, PluginStartEvent, get_metadata +from .plugins import PluginBase, PluginStartEvent from .routing import ActivityHandlerMixin, ActivityRouter from .routing.activity_context import ActivityContext from .token_manager import TokenManager @@ -111,22 +111,13 @@ def __init__(self, **options: Unpack[AppOptions]): plugins: List[PluginBase] = list(self.options.plugins) - http_plugin = None - for i, plugin in enumerate(plugins): - meta = get_metadata(type(plugin)) - if meta.name == "http": - http_plugin = plugin - plugins.pop(i) - break - - if not http_plugin: - http_plugin = HttpPlugin(logger=self.log, skip_auth=self.options.skip_auth) - - plugins.insert(0, http_plugin) - self.http = cast(HttpPlugin, http_plugin) + # Create HttpServer (not a plugin — owned directly by App) + adapter = self.options.http_server_adapter or FastAPIAdapter() + self.server = HttpServer(adapter, self.log) + self.container.set_provider("HttpServer", providers.Object(self.server)) + self.container.set_provider("server", providers.Object(self.server)) self._port: Optional[int] = None - self._running = False self._initialized = False # initialize ActivitySender for sending activities @@ -173,11 +164,6 @@ def port(self) -> Optional[int]: """Port the app is running on.""" return self._port - @property - def is_running(self) -> bool: - """Whether the app is currently running.""" - return self._running - @property def logger(self) -> Logger: """The logger instance used by the app.""" @@ -212,12 +198,16 @@ async def initialize(self) -> None: return try: + # Initialize plugins first (they may register routes, e.g. BotBuilder's /api/messages) for plugin in self.plugins: - # Inject the dependencies self._plugin_processor.inject(plugin) if hasattr(plugin, "on_init") and callable(plugin.on_init): await plugin.on_init() + # Initialize HttpServer (JWT validation + default /api/messages route) + self.server.on_request = self._process_activity_event + self.server.initialize(credentials=self.credentials, skip_auth=self.options.skip_auth) + self._initialized = True self.log.info("Teams app initialized successfully (without HTTP server)") @@ -226,6 +216,11 @@ async def initialize(self) -> None: self._events.emit("error", ErrorEvent(error, context={"method": "initialize"})) raise + async def _process_activity_event(self, event: Any) -> Any: + """Process an activity event through the app pipeline. Used as HttpServer.on_request callback.""" + await self.event_manager.on_activity(event) + return await self.activity_processor.process_activity(self.plugins, event) + async def start(self, port: Optional[int] = None) -> None: """ Start the Teams application and begin serving HTTP requests. @@ -236,10 +231,6 @@ async def start(self, port: Optional[int] = None) -> None: Args: port: Port to listen on (defaults to PORT env var or 3978) """ - if self._running: - self.log.warning("App is already running") - return - self._port = port or int(os.getenv("PORT", "3978")) try: @@ -247,49 +238,39 @@ async def start(self, port: Optional[int] = None) -> None: if not self._initialized: await self.initialize() - # Set callback and start HTTP plugin - async def on_http_ready() -> None: - self.log.info("Teams app started successfully") - assert self._port is not None, "Port must be set before emitting start event" - self._events.emit("start", StartEvent(port=self._port)) - self._running = True - - self.http.on_ready_callback = on_http_ready - + # Start plugins and HTTP server concurrently (both may block with serve()) tasks: List[Awaitable[Any]] = [] event = PluginStartEvent(port=self._port) for plugin in self.plugins: is_callable = hasattr(plugin, "on_start") and callable(plugin.on_start) if is_callable: tasks.append(plugin.on_start(event)) + + self.log.info("Teams app started successfully") + self._events.emit("start", StartEvent(port=self._port)) + + tasks.append(self.server.start(self._port)) await asyncio.gather(*tasks) except Exception as error: - self._running = False self.log.error(f"Failed to start app: {error}") self._events.emit("error", ErrorEvent(error, context={"method": "start", "port": self._port})) raise async def stop(self) -> None: """Stop the Teams application.""" - if not self._running: - return - try: - # Set callback and stop HTTP plugin first - async def on_http_stopped() -> None: - # Stop all other plugins after HTTP is stopped - for plugin in reversed(self.plugins): - is_callable = hasattr(plugin, "on_stop") and callable(plugin.on_stop) - if plugin is not self.http and is_callable: - await plugin.on_stop() + # Stop HTTP server first + await self.server.stop() - self._running = False - self.log.info("Teams app stopped") - self._events.emit("stop", StopEvent()) + # Stop all plugins + for plugin in reversed(self.plugins): + is_callable = hasattr(plugin, "on_stop") and callable(plugin.on_stop) + if is_callable: + await plugin.on_stop() - self.http.on_stopped_callback = on_http_stopped - await self.http.on_stop() + self.log.info("Teams app stopped") + self._events.emit("stop", StopEvent()) except Exception as error: self.log.error(f"Failed to stop app: {error}") @@ -475,7 +456,7 @@ def page(self, name: str, dir_path: str, page_path: Optional[str] = None) -> Non app.page("customform", os.path.join(os.path.dirname(__file__), "views", "customform"), "/tabs/dialog-form") ``` """ - self.http.mount(name, dir_path, page_path=page_path) + self.server.serve_static(page_path or f"/{name}", dir_path) def tab(self, name: str, path: str) -> None: """ @@ -511,23 +492,25 @@ def decorator(func: FCtx) -> FCtx: endpoint_name = name_or_func if isinstance(name_or_func, str) else func.__name__.replace("_", "-") self.logger.debug("Generated endpoint name for function '%s': %s", func.__name__, endpoint_name) - async def endpoint(req: Request): - middleware = remote_function_jwt_validation(self.log, self.entra_token_validator) - - async def call_next(r: Request) -> Any: - ctx = FunctionContext( - id=self.id, - api=self.api, - activity_sender=self.activity_sender, - log=self.log, - data=await r.json(), - **r.state.context.__dict__, - ) - return await func(ctx) - - return await middleware(req, call_next) + async def handler(request: HttpRequest) -> HttpResponse: + client_context, error = await validate_remote_function_request( + request["headers"], self.log, self.entra_token_validator + ) + if error or not client_context: + return HttpResponse(status=401, body={"detail": error or "unauthorized"}) + + ctx = FunctionContext( + id=self.id, + api=self.api, + activity_sender=self.activity_sender, + log=self.log, + data=request["body"], + **client_context.__dict__, + ) + result = await func(ctx) + return HttpResponse(status=200, body=result) - self.http.post(f"/api/functions/{endpoint_name}")(endpoint) + self.server.register_route("POST", f"/api/functions/{endpoint_name}", handler) return func # Direct decoration: @app.func diff --git a/packages/apps/src/microsoft_teams/apps/auth/__init__.py b/packages/apps/src/microsoft_teams/apps/auth/__init__.py index 6fa5f743..46b58cfe 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/auth/__init__.py @@ -4,7 +4,7 @@ """ from .jwt_middleware import create_jwt_validation_middleware -from .remote_function_jwt_middleware import remote_function_jwt_validation +from .remote_function_jwt_middleware import validate_remote_function_request from .token_validator import TokenValidator -__all__ = ["TokenValidator", "create_jwt_validation_middleware", "remote_function_jwt_validation"] +__all__ = ["TokenValidator", "create_jwt_validation_middleware", "validate_remote_function_request"] diff --git a/packages/apps/src/microsoft_teams/apps/auth/remote_function_jwt_middleware.py b/packages/apps/src/microsoft_teams/apps/auth/remote_function_jwt_middleware.py index 5e1b87aa..e9fe1ea8 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/remote_function_jwt_middleware.py +++ b/packages/apps/src/microsoft_teams/apps/auth/remote_function_jwt_middleware.py @@ -4,79 +4,89 @@ """ from logging import Logger -from typing import Any, Awaitable, Callable, Dict, List, Optional - -from fastapi import HTTPException, Request, Response +from typing import Dict, Optional from ..contexts import ClientContext from .token_validator import TokenValidator -def require_fields(fields: Dict[str, Optional[Any]], context: str, logger: Logger) -> None: - missing: List[str] = [name for name, value in fields.items() if not value] +def _require_fields(fields: Dict[str, Optional[str]], context: str, logger: Logger) -> Optional[str]: + """Validate required fields are present. Returns error message if any are missing, None otherwise.""" + missing = [name for name, value in fields.items() if not value] if missing: message = f"Missing or invalid fields in {context}: {', '.join(missing)}" logger.warning(message) - raise HTTPException(status_code=401, detail=message) + return message + return None -def remote_function_jwt_validation(logger: Logger, entra_token_validator: Optional[TokenValidator]): +async def validate_remote_function_request( + headers: Dict[str, str], + logger: Logger, + entra_token_validator: Optional[TokenValidator], +) -> tuple[Optional[ClientContext], Optional[str]]: """ - Middleware to validate JWT for remote function calls. + Validate JWT and extract client context from request headers for remote function calls. + Args: - entra_token_validator: TokenValidator instance for Entra ID tokens - logger: Logger instance + headers: Request headers dict. + logger: Logger instance. + entra_token_validator: TokenValidator instance for Entra ID tokens. Returns: - Middleware function that can be added to FastAPI app + Tuple of (ClientContext, None) on success or (None, error_message) on failure. """ + # Extract auth token + authorization = headers.get("Authorization") or headers.get("authorization") or "" + parts = authorization.split(" ") + auth_token = parts[1] if len(parts) == 2 and parts[0].lower() == "bearer" else "" - async def middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: - # Extract auth token - authorization = request.headers.get("Authorization", "") - parts = authorization.split(" ") - auth_token = parts[1] if len(parts) == 2 and parts[0].lower() == "bearer" else "" + # Validate headers + error = _require_fields( + { + "X-Teams-App-Session-Id": headers.get("X-Teams-App-Session-Id") or headers.get("x-teams-app-session-id"), + "X-Teams-Page-Id": headers.get("X-Teams-Page-Id") or headers.get("x-teams-page-id"), + "Authorization (Bearer token)": auth_token, + }, + "header", + logger, + ) + if error: + return None, error - # Validate headers - require_fields( - { - "X-Teams-App-Session-Id": request.headers.get("X-Teams-App-Session-Id"), - "X-Teams-Page-Id": request.headers.get("X-Teams-Page-Id"), - "Authorization (Bearer token)": auth_token, - }, - "header", - logger, - ) + if not entra_token_validator: + return None, "Token validator not configured" - if not entra_token_validator: - raise HTTPException(status_code=500, detail="Token validator not configured") + # Validate token + token_payload = await entra_token_validator.validate_token(auth_token) - # Validate token - token_payload = await entra_token_validator.validate_token(auth_token) + # Validate required fields in token + error = _require_fields( + {"oid": token_payload.get("oid"), "tid": token_payload.get("tid"), "name": token_payload.get("name")}, + "token payload", + logger, + ) + if error: + return None, error - # Validate required fields in token - require_fields( - {"oid": token_payload.get("oid"), "tid": token_payload.get("tid"), "name": token_payload.get("name")}, - "token payload", - logger, - ) + def _h(name: str) -> str: + """Get header value case-insensitively.""" + return headers.get(name) or headers.get(name.lower()) or "" - # Build context - request.state.context = ClientContext( - app_session_id=request.headers.get("X-Teams-App-Session-Id"), # type: ignore - tenant_id=token_payload["tid"], - user_id=token_payload["oid"], - user_name=token_payload["name"], - page_id=request.headers.get("X-Teams-Page-Id"), # type: ignore - auth_token=auth_token, # type: ignore - app_id=token_payload.get("appId"), - channel_id=request.headers.get("X-Teams-Channel-Id"), - chat_id=request.headers.get("X-Teams-Chat-Id"), - meeting_id=request.headers.get("X-Teams-Meeting-Id"), - message_id=request.headers.get("X-Teams-Message-Id"), - sub_page_id=request.headers.get("X-Teams-Sub-Page-Id"), - team_id=request.headers.get("X-Teams-Team-Id"), - ) - return await call_next(request) + context = ClientContext( + app_session_id=_h("X-Teams-App-Session-Id"), + tenant_id=token_payload["tid"], + user_id=token_payload["oid"], + user_name=token_payload["name"], + page_id=_h("X-Teams-Page-Id"), + auth_token=auth_token, + app_id=token_payload.get("appId"), + channel_id=_h("X-Teams-Channel-Id") or None, + chat_id=_h("X-Teams-Chat-Id") or None, + meeting_id=_h("X-Teams-Meeting-Id") or None, + message_id=_h("X-Teams-Message-Id") or None, + sub_page_id=_h("X-Teams-Sub-Page-Id") or None, + team_id=_h("X-Teams-Team-Id") or None, + ) - return middleware + return context, None diff --git a/packages/apps/src/microsoft_teams/apps/http/__init__.py b/packages/apps/src/microsoft_teams/apps/http/__init__.py new file mode 100644 index 00000000..f7751fd1 --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/http/__init__.py @@ -0,0 +1,18 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from .adapter import HttpMethod, HttpRequest, HttpResponse, HttpRouteHandler, HttpServerAdapter +from .fastapi_adapter import FastAPIAdapter +from .http_server import HttpServer + +__all__ = [ + "HttpMethod", + "HttpRequest", + "HttpResponse", + "HttpRouteHandler", + "HttpServer", + "HttpServerAdapter", + "FastAPIAdapter", +] diff --git a/packages/apps/src/microsoft_teams/apps/http/adapter.py b/packages/apps/src/microsoft_teams/apps/http/adapter.py new file mode 100644 index 00000000..bb99c9cb --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/http/adapter.py @@ -0,0 +1,39 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Dict, Literal, Protocol, TypedDict, runtime_checkable + +HttpMethod = Literal["POST"] + + +class HttpRequest(TypedDict): + body: Dict[str, object] + headers: Dict[str, str] + + +class HttpResponse(TypedDict): + status: int + body: object + + +class HttpRouteHandler(Protocol): + async def __call__(self, request: HttpRequest) -> HttpResponse: ... + + +@runtime_checkable +class HttpServerAdapter(Protocol): + def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None: + """Register a route handler. Required.""" + ... + + def serve_static(self, path: str, directory: str) -> None: + """Serve static files from a directory. Optional — no-op by default.""" + + async def start(self, port: int) -> None: + """Start the server. Optional — raises if not implemented.""" + raise NotImplementedError("This adapter does not support managed server lifecycle. Start the server yourself.") + + async def stop(self) -> None: + """Stop the server. Optional — no-op by default.""" diff --git a/packages/apps/src/microsoft_teams/apps/http/fastapi_adapter.py b/packages/apps/src/microsoft_teams/apps/http/fastapi_adapter.py new file mode 100644 index 00000000..93da930e --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/http/fastapi_adapter.py @@ -0,0 +1,76 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Callable, Dict, Optional + +import uvicorn +from fastapi import FastAPI, Request, Response +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles + +from .adapter import HttpMethod, HttpRequest, HttpResponse, HttpRouteHandler + + +class FastAPIAdapter: + """Default HttpServerAdapter implementation wrapping FastAPI + uvicorn.""" + + def __init__( + self, + app: Optional[FastAPI] = None, + server_factory: Optional[Callable[[FastAPI], uvicorn.Server]] = None, + ): + self._fastapi = app or FastAPI() + self._server: Optional[uvicorn.Server] = None + self._server_factory = server_factory + + if server_factory: + self._server = server_factory(self._fastapi) + if self._server.config.app is not self._fastapi: + raise ValueError( + "server_factory must return a uvicorn.Server configured with the provided FastAPI app instance." + ) + + @property + def app(self) -> FastAPI: + """The underlying FastAPI instance.""" + return self._fastapi + + def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None: + """Register a route handler on the FastAPI app.""" + + async def fastapi_handler(request: Request) -> Response: + 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"] + resp_body = result.get("body") + if resp_body is not None: + return JSONResponse(content=resp_body, status_code=status) + return Response(status_code=status) + + assert method == "POST", f"Unsupported HTTP method: {method}" + self._fastapi.post(path)(fastapi_handler) + + def serve_static(self, path: str, directory: str) -> None: + """Mount a static files directory.""" + name = path.strip("/").replace("/", "-") or "static" + self._fastapi.mount(path, StaticFiles(directory=directory, check_dir=True, html=True), name=name) + + async def start(self, port: int) -> None: + """Start the uvicorn server. Blocks until stopped.""" + if self._server: + if self._server.config.port != port: + pass # User's factory takes precedence + else: + config = uvicorn.Config(app=self._fastapi, host="0.0.0.0", port=port, log_level="info") + self._server = uvicorn.Server(config) + + await self._server.serve() + + async def stop(self) -> None: + """Signal the server to stop.""" + if self._server: + self._server.should_exit = True diff --git a/packages/apps/src/microsoft_teams/apps/http/http_server.py b/packages/apps/src/microsoft_teams/apps/http/http_server.py new file mode 100644 index 00000000..d6b6f410 --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/http/http_server.py @@ -0,0 +1,178 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from logging import Logger +from types import SimpleNamespace +from typing import Any, Awaitable, Callable, Dict, Optional, cast + +from microsoft_teams.api import Credentials, InvokeResponse, TokenProtocol +from microsoft_teams.api.auth.json_web_token import JsonWebToken +from pydantic import BaseModel + +from ..auth import TokenValidator +from ..events import ActivityEvent, CoreActivity +from .adapter import HttpMethod, HttpRequest, HttpResponse, HttpRouteHandler, HttpServerAdapter + + +class HttpServer: + """ + Core Teams HTTP server. Not a plugin — owned directly by the App. + + Manages an HttpServerAdapter instance and handles JWT validation + and activity processing for the Teams protocol. + """ + + def __init__(self, adapter: HttpServerAdapter, logger: Logger): + self._adapter = adapter + self._logger = logger + self._on_request: Optional[Callable[[ActivityEvent], Awaitable[InvokeResponse[Any]]]] = None + self._token_validator: Optional[TokenValidator] = None + self._skip_auth: bool = False + self._initialized: bool = False + + @property + def adapter(self) -> HttpServerAdapter: + """The underlying HttpServerAdapter.""" + return self._adapter + + @property + def on_request(self) -> Optional[Callable[[ActivityEvent], Awaitable[InvokeResponse[Any]]]]: + """Callback set by App to process activities.""" + return self._on_request + + @on_request.setter + def on_request(self, callback: Optional[Callable[[ActivityEvent], Awaitable[InvokeResponse[Any]]]]) -> None: + self._on_request = callback + + def initialize( + self, + credentials: Optional[Credentials] = None, + skip_auth: bool = False, + ) -> None: + """ + Set up JWT validation and register the default POST /api/messages route. + + Args: + credentials: App credentials for JWT validation. + skip_auth: Whether to skip JWT validation. + """ + if self._initialized: + return + + self._skip_auth = skip_auth + + app_id = getattr(credentials, "client_id", None) if credentials else None + if app_id and not skip_auth: + self._token_validator = TokenValidator.for_service(app_id, self._logger) + self._logger.debug("JWT validation enabled for /api/messages") + + self._adapter.register_route("POST", "/api/messages", self._handle_activity) + self._initialized = True + + async def _handle_activity(self, request: HttpRequest) -> HttpResponse: + """Handle incoming activity on POST /api/messages.""" + try: + body = request["body"] + headers = request["headers"] + + # Validate JWT token + authorization = headers.get("authorization") or headers.get("Authorization") or "" + + if self._token_validator and not self._skip_auth: + if not authorization.startswith("Bearer "): + return HttpResponse(status=401, body={"error": "Unauthorized"}) + + raw_token = authorization.removeprefix("Bearer ") + service_url = cast(Optional[str], body.get("serviceUrl")) + + try: + await self._token_validator.validate_token(raw_token, service_url) + except Exception as e: + self._logger.warning(f"JWT token validation failed: {e}") + return HttpResponse(status=401, body={"error": "Unauthorized"}) + + token: TokenProtocol = cast(TokenProtocol, JsonWebToken(value=raw_token)) + else: + # No auth — use a default token + service_url = cast(Optional[str], body.get("serviceUrl")) + token = cast( + TokenProtocol, + SimpleNamespace( + app_id="", + app_display_name="", + tenant_id="", + service_url=service_url or "", + from_="azure", + from_id="", + is_expired=lambda: False, + ), + ) + + core_activity = CoreActivity.model_validate(body) + activity_type = core_activity.type or "unknown" + activity_id = core_activity.id or "unknown" + self._logger.debug(f"Received activity: {activity_type} (ID: {activity_id})") + + # Process the activity via the App callback + result = await self._process_activity(core_activity, token) + return self._format_response(result) + except Exception as e: + self._logger.exception(str(e)) + return HttpResponse(status=500, body={"error": "Internal server error"}) + + async def _process_activity(self, core_activity: CoreActivity, token: TokenProtocol) -> InvokeResponse[Any]: + """Process an activity via the registered on_request callback.""" + result: InvokeResponse[Any] + try: + event = ActivityEvent(body=core_activity, token=token) + if self._on_request: + result = await self._on_request(event) + else: + self._logger.warning("No on_request handler registered") + result = InvokeResponse(status=500) + except Exception as error: + self._logger.exception(str(error)) + result = InvokeResponse(status=500) + + return result + + def _format_response(self, result: Any) -> HttpResponse: + """Format an InvokeResponse into an HttpResponse.""" + status_code: int = 200 + body: Optional[Any] = None + + resp_dict: Optional[Dict[str, Any]] = None + if isinstance(result, dict): + resp_dict = result + elif isinstance(result, BaseModel): + resp_dict = result.model_dump(exclude_none=True) + + if resp_dict and "status" in resp_dict: + status_code = resp_dict.get("status", 200) + + if resp_dict and "body" in resp_dict: + body = resp_dict.get("body") + + if body is not None: + return HttpResponse(status=status_code, body=body) + return HttpResponse(status=status_code, body=None) + + def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None: + """Delegate route registration to the adapter.""" + self._adapter.register_route(method, path, handler) + + def serve_static(self, path: str, directory: str) -> None: + """Delegate static file serving to the adapter.""" + self._adapter.serve_static(path, directory) + + async def start(self, port: int) -> None: + """Start the HTTP server. Blocks until stopped.""" + self._logger.info(f"Starting HTTP server on port {port}") + await self._adapter.start(port) + + async def stop(self) -> None: + """Stop the HTTP server.""" + self._logger.info("Stopping HTTP server") + await self._adapter.stop() diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py index 6bd5846c..f56f0dd7 100644 --- a/packages/apps/src/microsoft_teams/apps/options.py +++ b/packages/apps/src/microsoft_teams/apps/options.py @@ -3,6 +3,8 @@ Licensed under the MIT License. """ +from __future__ import annotations + from dataclasses import dataclass, field from logging import Logger from typing import Any, Awaitable, Callable, List, Optional, TypedDict, Union, cast @@ -11,6 +13,7 @@ from microsoft_teams.common import Storage from typing_extensions import Unpack +from .http.adapter import HttpServerAdapter from .plugins import PluginBase @@ -42,6 +45,10 @@ class AppOptions(TypedDict, total=False): plugins: Optional[List[PluginBase]] skip_auth: Optional[bool] + # HTTP adapter + http_server_adapter: Optional[HttpServerAdapter] + """Custom HTTP server adapter. Defaults to FastAPIAdapter if not provided.""" + # OAuth default_connection_name: Optional[str] """The OAuth connection name to use for authentication. Defaults to 'graph'.""" @@ -95,6 +102,8 @@ class InternalAppOptions: Uses environment variable SERVICE_URL if not provided and defaults to https://smba.trafficmanager.net/teams """ + http_server_adapter: Optional[HttpServerAdapter] = None + """Custom HTTP server adapter. Defaults to FastAPIAdapter if not provided.""" @classmethod def from_typeddict(cls, options: AppOptions) -> "InternalAppOptions": diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py index ac41420b..88638b44 100644 --- a/packages/apps/tests/test_app.py +++ b/packages/apps/tests/test_app.py @@ -94,20 +94,17 @@ def basic_options(self, mock_logger, mock_storage): client_secret="test-secret", ) - def _mock_http_plugin(self, app: App) -> App: - """Helper to mock the HTTP plugin's create_stream and close methods.""" - mock_stream = MagicMock() - mock_stream.events = MagicMock() - mock_stream.events.on = MagicMock() - mock_stream.close = AsyncMock() - app.http.create_stream = MagicMock(return_value=mock_stream) + def _mock_http_server(self, app: App) -> App: + """Helper to mock the HTTP server methods.""" + app.server.start = AsyncMock() # type: ignore[method-assign] + app.server.stop = AsyncMock() # type: ignore[method-assign] return app @pytest.fixture(scope="function") def app_with_options(self, basic_options): """Create App with basic options.""" app = App(**basic_options) - return self._mock_http_plugin(app) + return self._mock_http_server(app) @pytest.fixture(scope="function") def app_with_activity_handler(self, mock_logger, mock_storage, mock_activity_handler): @@ -121,51 +118,33 @@ def app_with_activity_handler(self, mock_logger, mock_storage, mock_activity_han ) app = App(**options) app.on_activity(mock_activity_handler) - return self._mock_http_plugin(app) + return self._mock_http_server(app) def test_app_starts_successfully(self, basic_options): """Test that app can be created and initialized.""" app = App(**basic_options) - # Basic functional test - app should be created and not running - assert not app.is_running + # Basic functional test - app should be created assert app.port is None @pytest.mark.asyncio async def test_app_lifecycle_start_stop(self, app_with_options): """Test basic app lifecycle: start and stop.""" - # Mock the underlying HTTP server to avoid actual server startup - async def mock_on_start(event): - # Simulate the HTTP plugin calling the ready callback - if app_with_options.http.on_ready_callback: - await app_with_options.http.on_ready_callback() - - with patch.object(app_with_options.http, "on_start", new_callable=AsyncMock, side_effect=mock_on_start): - # Test start - start_task = asyncio.create_task(app_with_options.start(3978)) - await asyncio.sleep(0.1) - - # App should be running and have correct port - assert app_with_options.is_running - assert app_with_options.port == 3978 - - start_task.cancel() - try: - await start_task - except asyncio.CancelledError: - pass + # Test start — server.start is already mocked by _mock_http_server + start_task = asyncio.create_task(app_with_options.start(3978)) + await asyncio.sleep(0.1) - # Test stop - app_with_options._running = True + assert app_with_options.port == 3978 - async def mock_on_stop(): - if app_with_options.http.on_stopped_callback: - await app_with_options.http.on_stopped_callback() + start_task.cancel() + try: + await start_task + except asyncio.CancelledError: + pass - with patch.object(app_with_options.http, "on_stop", new_callable=AsyncMock, side_effect=mock_on_stop): - await app_with_options.stop() - assert not app_with_options.is_running + # Test stop + await app_with_options.stop() # Event Testing - Focus on functional behavior @@ -187,9 +166,7 @@ async def handle_activity(event: ActivityEvent) -> None: id="test-activity-id", ) - await app_with_activity_handler.event_manager.on_activity( - ActivityEvent(body=core_activity, token=FakeToken()) - ) + await app_with_activity_handler.event_manager.on_activity(ActivityEvent(body=core_activity, token=FakeToken())) # Wait for the async event handler to complete await asyncio.wait_for(event_received.wait(), timeout=1.0) @@ -232,9 +209,7 @@ async def handle_activity_2(event: ActivityEvent) -> None: id="test-activity-id", ) - await app_with_options.event_manager.on_activity( - ActivityEvent(body=core_activity, token=FakeToken()) - ) + await app_with_options.event_manager.on_activity(ActivityEvent(body=core_activity, token=FakeToken())) # Wait for both async event handlers to complete await asyncio.wait_for(both_received.wait(), timeout=1.0) @@ -500,9 +475,9 @@ async def logging_middleware(ctx: ActivityContext) -> None: @pytest.mark.asyncio async def test_func_decorator_registration(self, app_with_options: App): """Simple test that @app.func registers a function.""" - app_with_options.http.post = MagicMock() + mock_register = MagicMock() + app_with_options.server.register_route = mock_register # type: ignore[method-assign] - # Dummy request to simulate a call async def dummy_func(ctx): return "called" @@ -510,8 +485,10 @@ async def dummy_func(ctx): assert decorated == dummy_func # Extract the endpoint path it was registered to - endpoint_path = app_with_options.http.post.call_args[0][0] - assert endpoint_path == f"/api/functions/{dummy_func.__name__.replace('_', '-')}" + mock_register.assert_called_once() + method, path, handler = mock_register.call_args[0] + assert method == "POST" + assert path == f"/api/functions/{dummy_func.__name__.replace('_', '-')}" def test_user_agent_format(self, app_with_options: App): """Test that USER_AGENT follows the expected format teams.py[apps]/{version}.""" diff --git a/packages/apps/tests/test_app_events.py b/packages/apps/tests/test_app_events.py index a4caad5d..a93e9615 100644 --- a/packages/apps/tests/test_app_events.py +++ b/packages/apps/tests/test_app_events.py @@ -13,7 +13,7 @@ ActivityResponseEvent, ActivitySentEvent, ErrorEvent, - HttpPlugin, + PluginBase, ) from microsoft_teams.apps.app_events import EventManager from microsoft_teams.apps.events import CoreActivity @@ -35,12 +35,12 @@ def event_manager(self, mock_event_emitter): @pytest.fixture def mock_plugins(self): - plugin = MagicMock(spec=HttpPlugin) + plugin = MagicMock(spec=PluginBase) plugin.on_error_event = AsyncMock() plugin.on_error = AsyncMock() plugin.on_activity_sent = AsyncMock() plugin.on_activity_response = AsyncMock() - plugin_two = MagicMock(spec=HttpPlugin) + plugin_two = MagicMock(spec=PluginBase) return [plugin, plugin_two] @pytest.mark.asyncio diff --git a/packages/apps/tests/test_app_process.py b/packages/apps/tests/test_app_process.py index 27a66fa0..e760591a 100644 --- a/packages/apps/tests/test_app_process.py +++ b/packages/apps/tests/test_app_process.py @@ -136,7 +136,7 @@ async def test_process_activity_middleware_results(self, activity_processor, mid "conversation": {"id": "conv-789"}, "recipient": {"id": "bot-456", "name": "Test Bot"}, "channelId": "msteams", - } + }, ) mock_token = MagicMock(spec=TokenProtocol) mock_token.service_url = "https://service.url" @@ -172,7 +172,7 @@ async def test_process_activity_raises_exception(self, activity_processor): "conversation": {"id": "conv-789"}, "recipient": {"id": "bot-456", "name": "Test Bot"}, "channelId": "msteams", - } + }, ) mock_token = MagicMock(spec=TokenProtocol) mock_token.service_url = "https://service.url" diff --git a/packages/apps/tests/test_http_server.py b/packages/apps/tests/test_http_server.py new file mode 100644 index 00000000..4ecc3830 --- /dev/null +++ b/packages/apps/tests/test_http_server.py @@ -0,0 +1,241 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from microsoft_teams.api import ( + ConfigResponse, + InvokeResponse, +) +from microsoft_teams.apps.http import FastAPIAdapter, HttpServer +from microsoft_teams.apps.http.adapter import HttpRequest, HttpResponse + + +class TestHttpServer: + """Test cases for HttpServer public interface.""" + + @pytest.fixture + def mock_logger(self): + """Create a mock logger.""" + return MagicMock() + + @pytest.fixture + def mock_adapter(self): + """Create a mock adapter.""" + adapter = MagicMock() + adapter.register_route = MagicMock() + adapter.serve_static = MagicMock() + adapter.start = AsyncMock() + adapter.stop = AsyncMock() + return adapter + + @pytest.fixture + def server(self, mock_adapter, mock_logger): + """Create HttpServer with mock adapter.""" + return HttpServer(mock_adapter, mock_logger) + + def test_init(self, server, mock_adapter): + """Test HttpServer initialization.""" + assert server.adapter is mock_adapter + assert server.on_request is None + + def test_initialize_registers_route(self, server, mock_adapter): + """Test that initialize registers the /api/messages route.""" + 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" + + def test_on_request_setter(self, server): + """Test on_request callback setter.""" + + async def handler(event): + return InvokeResponse(status=200) + + server.on_request = handler + assert server.on_request is handler + + @pytest.mark.asyncio + async def test_handle_activity_success(self, server): + """Test successful activity handling.""" + expected_body = {"status": "success"} + expected_response = InvokeResponse(body=cast(ConfigResponse, expected_body), status=200) + + async def mock_handler(event): + return expected_response + + server.on_request = mock_handler + server.initialize(skip_auth=True) + + request = HttpRequest( + body={ + "type": "message", + "id": "test-123", + "text": "Test message", + }, + headers={}, + ) + + result = await server._handle_activity(request) + + assert result["status"] == 200 + assert result["body"] == expected_body + + @pytest.mark.asyncio + async def test_handle_activity_exception(self, server, mock_logger): + """Test activity handling when handler raises exception.""" + + async def failing_handler(event): + raise ValueError("Handler failed") + + server.on_request = failing_handler + server.initialize(skip_auth=True) + + request = HttpRequest( + body={"type": "message", "id": "test-123"}, + headers={}, + ) + + result = await server._handle_activity(request) + + assert result["status"] == 500 + mock_logger.exception.assert_called_once() + + @pytest.mark.asyncio + async def test_handle_activity_no_handler(self, server, mock_logger): + """Test activity handling when no on_request handler is set.""" + server.initialize(skip_auth=True) + + request = HttpRequest( + body={"type": "message", "id": "test-123"}, + headers={}, + ) + + result = await server._handle_activity(request) + + assert result["status"] == 500 + mock_logger.warning.assert_called() + + @pytest.mark.asyncio + async def test_start(self, server, mock_adapter): + """Test server start delegates to adapter.""" + await server.start(3978) + mock_adapter.start.assert_called_once_with(3978) + + @pytest.mark.asyncio + async def test_stop(self, server, mock_adapter): + """Test server stop delegates to adapter.""" + await server.stop() + mock_adapter.stop.assert_called_once() + + def test_register_route_delegates(self, server, mock_adapter): + """Test register_route delegates to adapter.""" + + async def handler(req): + return HttpResponse(status=200, body=None) + + server.register_route("POST", "/custom", handler) + mock_adapter.register_route.assert_called_once_with("POST", "/custom", handler) + + def test_serve_static_delegates(self, server, mock_adapter): + """Test serve_static delegates to adapter.""" + server.serve_static("/static", "/path/to/dir") + mock_adapter.serve_static.assert_called_once_with("/static", "/path/to/dir") + + +class TestFastAPIAdapter: + """Test cases for FastAPIAdapter.""" + + def test_init_creates_fastapi_app(self): + """Test FastAPIAdapter creates a FastAPI app.""" + from fastapi import FastAPI + + adapter = FastAPIAdapter() + assert isinstance(adapter.app, FastAPI) + + def test_register_route(self): + """Test route registration on FastAPI app.""" + adapter = FastAPIAdapter() + + async def handler(req: HttpRequest) -> HttpResponse: + return HttpResponse(status=200, body=None) + + adapter.register_route("POST", "/test", handler) + + # Verify a route was registered on the FastAPI app + routes = [r for r in adapter.app.routes if hasattr(r, "path") and r.path == "/test"] + assert len(routes) == 1 + + def test_serve_static(self, tmp_path): + """Test static file mounting.""" + adapter = FastAPIAdapter() + adapter.serve_static("/static", str(tmp_path)) + + # Verify a mount was registered + 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_creates_uvicorn_server(self): + """Test that start creates and starts a uvicorn server.""" + adapter = FastAPIAdapter() + + mock_server = MagicMock() + mock_server.serve = AsyncMock() + + with ( + patch("microsoft_teams.apps.http.fastapi_adapter.uvicorn.Config") as mock_config, + patch("microsoft_teams.apps.http.fastapi_adapter.uvicorn.Server", return_value=mock_server), + ): + mock_config.return_value = MagicMock() + await adapter.start(3978) + + mock_config.assert_called_once() + mock_server.serve.assert_called_once() + + @pytest.mark.asyncio + async def test_stop(self): + """Test stop signals the server.""" + adapter = FastAPIAdapter() + mock_server = MagicMock() + adapter._server = mock_server + + await adapter.stop() + + assert mock_server.should_exit is True + + @pytest.mark.asyncio + async def test_stop_no_server(self): + """Test stop when no server is running.""" + adapter = FastAPIAdapter() + # Should not raise + await adapter.stop() + + def test_server_factory(self): + """Test custom server factory.""" + mock_server = MagicMock() + + def factory(app): + mock_server.config = MagicMock() + mock_server.config.app = app + return mock_server + + adapter = FastAPIAdapter(server_factory=factory) + assert adapter._server is mock_server + + def test_server_factory_wrong_app_raises(self): + """Test that server factory with wrong app raises.""" + mock_server = MagicMock() + mock_server.config.app = MagicMock() # Different app instance + + def factory(app): + return mock_server + + with pytest.raises(ValueError, match="server_factory must return"): + FastAPIAdapter(server_factory=factory) diff --git a/packages/botbuilder/src/microsoft_teams/botbuilder/botbuilder_plugin.py b/packages/botbuilder/src/microsoft_teams/botbuilder/botbuilder_plugin.py index bc0aae80..5a9baf7c 100644 --- a/packages/botbuilder/src/microsoft_teams/botbuilder/botbuilder_plugin.py +++ b/packages/botbuilder/src/microsoft_teams/botbuilder/botbuilder_plugin.py @@ -6,18 +6,19 @@ import importlib.metadata from logging import Logger from types import SimpleNamespace -from typing import Annotated, Any, Optional, Unpack, cast +from typing import Annotated, Any, Callable, Optional, TypedDict, Unpack, cast -from fastapi import HTTPException, Request, Response -from microsoft_teams.api import Credentials +from microsoft_teams.api import Credentials, InvokeResponse from microsoft_teams.apps import ( DependencyMetadata, - HttpPlugin, + EventMetadata, + HttpServer, LoggerDependencyOptions, Plugin, + PluginBase, ) -from microsoft_teams.apps.events import CoreActivity -from microsoft_teams.apps.http_plugin import HttpPluginOptions +from microsoft_teams.apps.events import ActivityEvent, CoreActivity, ErrorEvent +from microsoft_teams.apps.http import HttpRequest, HttpResponse from botbuilder.core import ( ActivityHandler, @@ -36,15 +37,15 @@ MULTI_TENANT = "multitenant" -class BotBuilderPluginOptions(HttpPluginOptions, total=False): +class BotBuilderPluginOptions(TypedDict, total=False): """Options for configuring the BotBuilder plugin.""" handler: ActivityHandler adapter: CloudAdapter -@Plugin(name="http", version=version, description="BotBuilder plugin for Microsoft Bot Framework integration") -class BotBuilderPlugin(HttpPlugin): +@Plugin(name="botbuilder", version=version, description="BotBuilder plugin for Microsoft Bot Framework integration") +class BotBuilderPlugin(PluginBase): """ BotBuilder plugin that provides Microsoft Bot Framework integration. """ @@ -52,6 +53,10 @@ class BotBuilderPlugin(HttpPlugin): # Dependency injections logger: Annotated[Logger, LoggerDependencyOptions()] credentials: Annotated[Optional[Credentials], DependencyMetadata(optional=True)] + server: Annotated[HttpServer, DependencyMetadata()] + + on_error_event: Annotated[Callable[[ErrorEvent], None], EventMetadata(name="error")] + on_activity_event: Annotated[Callable[[ActivityEvent], InvokeResponse[Any]], EventMetadata(name="activity")] def __init__(self, **options: Unpack[BotBuilderPluginOptions]): """ @@ -60,16 +65,12 @@ def __init__(self, **options: Unpack[BotBuilderPluginOptions]): Args: options: Configuration options for the plugin """ - + super().__init__() self.handler: Optional[ActivityHandler] = options.get("handler") self.adapter: Optional[CloudAdapter] = options.get("adapter") - super().__init__(**options) - async def on_init(self) -> None: """Initialize the plugin when the app starts.""" - await super().on_init() - if not self.adapter: # Extract credentials for Bot Framework authentication client_id: Optional[str] = None @@ -93,46 +94,73 @@ async def on_init(self) -> None: self.logger.debug("BotBuilder plugin initialized successfully") - async def on_activity_request(self, core_activity: CoreActivity, request: Request, response: Response) -> Any: - """ - Handles an incoming activity. + # Register the activity route via HttpServer + self.server.register_route("POST", "/api/messages", self._handle_activity) - Overrides the base HTTP plugin behavior to: - 1. Process the activity using the Bot Framework adapter/handler. - 2. Then pass the request to the parent Teams plugin pipeline. + async def _handle_activity(self, request: HttpRequest) -> HttpResponse: + """ + Pure handler for POST /api/messages. - Returns the final HTTP response. + Processes via Bot Framework, then passes to the Teams pipeline. """ if not self.adapter: raise RuntimeError("plugin not registered") + body = request["body"] + headers = request["headers"] + try: - # Parse activity data from core_activity - activity_dict = core_activity.model_dump(by_alias=True, exclude_none=True) - activity_bf = cast(Activity, Activity().deserialize(activity_dict)) + # Parse activity from body + activity_bf = cast(Activity, Activity().deserialize(body)) - # A POST request must contain an Activity if not activity_bf.type: - raise HTTPException(status_code=400, detail="Missing activity type") + return HttpResponse(status=400, body={"detail": "Missing activity type"}) - async def logic(turn_context: TurnContext): + async def logic(turn_context: TurnContext) -> None: if not turn_context.activity.id: return - # Handle activity with botframework handler if self.handler: await self.handler.on_turn(turn_context) # Grab the auth header from the inbound request - auth_header = request.headers["Authorization"] if "Authorization" in request.headers else "" + auth_header = headers.get("authorization") or headers.get("Authorization") or "" await self.adapter.process_activity(auth_header, activity_bf, logic) - # Call parent's on_activity_request to handle Teams processing - return await super().on_activity_request(core_activity, request, response) + # Process through Teams pipeline + core_activity = CoreActivity.model_validate(body) + token = cast( + Any, + SimpleNamespace( + app_id="", + app_display_name="", + tenant_id="", + service_url=core_activity.service_url or "", + from_="azure", + from_id="", + is_expired=lambda: False, + ), + ) + + result = await self.on_activity_event(ActivityEvent(body=core_activity, token=token)) + + # Format response + status_code = 200 + resp_body: Any = None + if hasattr(result, "model_dump"): + resp_dict = result.model_dump(exclude_none=True) + elif isinstance(result, dict): + resp_dict = result + else: + resp_dict = {} + + if "status" in resp_dict: + status_code = resp_dict.get("status", 200) + if "body" in resp_dict: + resp_body = resp_dict.get("body") + + return HttpResponse(status=status_code, body=resp_body) - except HTTPException as http_err: - self.logger.error(f"HTTP error processing activity: {http_err}", exc_info=True) - raise except Exception as err: self.logger.error(f"Error processing activity: {err}", exc_info=True) - raise HTTPException(status_code=500, detail=str(err)) from err + return HttpResponse(status=500, body={"detail": str(err)}) diff --git a/packages/botbuilder/tests/test_botbuilder_plugin.py b/packages/botbuilder/tests/test_botbuilder_plugin.py index 0f604b10..477185aa 100644 --- a/packages/botbuilder/tests/test_botbuilder_plugin.py +++ b/packages/botbuilder/tests/test_botbuilder_plugin.py @@ -10,8 +10,9 @@ from botbuilder.core import ActivityHandler, TurnContext from botbuilder.integration.aiohttp import CloudAdapter from botbuilder.schema import Activity -from fastapi import HTTPException, Request, Response -from microsoft_teams.api import Credentials +from microsoft_teams.api import Credentials, InvokeResponse +from microsoft_teams.apps.http import HttpServer +from microsoft_teams.apps.http.adapter import HttpRequest from microsoft_teams.botbuilder import BotBuilderPlugin @@ -24,20 +25,26 @@ def mock_logger(self): @pytest.fixture def plugin_without_adapter(self): - plugin = BotBuilderPlugin(skip_auth=True) + plugin = BotBuilderPlugin() plugin.credentials = MagicMock(spec=Credentials) plugin.credentials.client_id = "abc" plugin.credentials.client_secret = "secret" plugin.credentials.tenant_id = "tenant-123" + plugin.server = MagicMock(spec=HttpServer) + plugin.logger = MagicMock() return plugin @pytest.fixture def plugin_with_adapter(self) -> BotBuilderPlugin: adapter = MagicMock(spec=CloudAdapter) - plugin = BotBuilderPlugin(adapter=adapter, skip_auth=True) - plugin._handle_activity_request = AsyncMock(return_value="fake_result") # pyright: ignore[reportPrivateUsage] + plugin = BotBuilderPlugin(adapter=adapter) handler = AsyncMock(spec=ActivityHandler) plugin.handler = handler + plugin.server = MagicMock(spec=HttpServer) + plugin.logger = MagicMock() + + # Set up the on_activity_event handler + plugin.on_activity_event = AsyncMock(return_value=InvokeResponse(status=200)) return plugin @pytest.mark.asyncio @@ -57,9 +64,15 @@ async def test_on_init_creates_adapter_when_missing(self, plugin_without_adapter mock_adapter_class.assert_called_once() assert plugin_without_adapter.adapter == "mock_adapter" + # Should have registered route via server + plugin_without_adapter.server.register_route.assert_called_once() + call_args = plugin_without_adapter.server.register_route.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/messages" + @pytest.mark.asyncio - async def test_on_activity_request_calls_adapter_and_handler(self, plugin_with_adapter: BotBuilderPlugin): - # Mock request and response + async def test_handle_activity_calls_adapter_and_handler(self, plugin_with_adapter: BotBuilderPlugin): + """Test that _handle_activity calls adapter and handler.""" activity_data = { "type": "message", "id": "activity-id", @@ -68,27 +81,20 @@ async def test_on_activity_request_calls_adapter_and_handler(self, plugin_with_a "conversation": {"id": "conv1"}, "serviceUrl": "https://service.url", } - from microsoft_teams.apps.events import CoreActivity - - request = AsyncMock(spec=Request) - request.json.return_value = activity_data - request.headers = {"Authorization": "Bearer token"} - response = MagicMock(spec=Response) + request = HttpRequest( + body=activity_data, + headers={"Authorization": "Bearer token"}, + ) # Mock adapter.process_activity to call logic with a mock TurnContext - async def fake_process_activity(auth_header, activity, logic): # type: ignore - print("Inside fake_process_activity") + async def fake_process_activity(auth_header, activity, logic): await logic(MagicMock(spec=TurnContext)) assert plugin_with_adapter.adapter is not None - plugin_with_adapter.adapter.process_activity = AsyncMock(side_effect=fake_process_activity) - # Convert activity_data to CoreActivity - core_activity = CoreActivity(**activity_data) - - await plugin_with_adapter.on_activity_request(core_activity, request, response) + result = await plugin_with_adapter._handle_activity(request) # Ensure adapter.process_activity called with correct auth and activity plugin_with_adapter.adapter.process_activity.assert_called_once() @@ -97,27 +103,25 @@ async def fake_process_activity(auth_header, activity, logic): # type: ignore assert isinstance(called_activity, Activity) # Ensure handler called via TurnContext - plugin_with_adapter.handler.on_turn.assert_awaited() # type: ignore + plugin_with_adapter.handler.on_turn.assert_awaited() - @pytest.mark.asyncio - async def test_on_activity_request_raises_http_exception_on_adapter_error( - self, plugin_with_adapter: BotBuilderPlugin - ): - from microsoft_teams.apps.events import CoreActivity + # Should return a valid HttpResponse + assert result["status"] == 200 + @pytest.mark.asyncio + async def test_handle_activity_returns_error_on_adapter_error(self, plugin_with_adapter: BotBuilderPlugin): + """Test that _handle_activity returns 500 on adapter error.""" activity_data = {"type": "message", "id": "activity-id"} - request = AsyncMock(spec=Request) - request.json.return_value = activity_data - request.headers = {} - response = MagicMock(spec=Response) - assert plugin_with_adapter.adapter is not None + request = HttpRequest( + body=activity_data, + headers={}, + ) + assert plugin_with_adapter.adapter is not None plugin_with_adapter.adapter.process_activity = AsyncMock(side_effect=Exception("fail")) - # Convert activity_data to CoreActivity - core_activity = CoreActivity(**activity_data) + result = await plugin_with_adapter._handle_activity(request) - with pytest.raises(HTTPException) as exc: - await plugin_with_adapter.on_activity_request(core_activity, request, response) - assert exc.value.status_code == 500 + assert result["status"] == 500 + assert result["body"]["detail"] == "fail" diff --git a/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py b/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py index 5f8d6b70..3b2d0188 100644 --- a/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py +++ b/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py @@ -23,7 +23,6 @@ DependencyMetadata, ErrorEvent, EventMetadata, - HttpPlugin, LoggerDependencyOptions, Plugin, PluginActivityEvent, @@ -49,7 +48,6 @@ class DevToolsPlugin(PluginBase): logger: Annotated[Logger, LoggerDependencyOptions()] id: Annotated[Optional[TokenProtocol], DependencyMetadata(optional=True)] - http: Annotated[HttpPlugin, DependencyMetadata()] on_error_event: Annotated[Callable[[ErrorEvent], None], EventMetadata(name="error")] on_activity_event: Annotated[Callable[[ActivityEvent], None], EventMetadata(name="activity")] diff --git a/packages/mcpplugin/src/microsoft_teams/mcpplugin/server_plugin.py b/packages/mcpplugin/src/microsoft_teams/mcpplugin/server_plugin.py index f56bdf13..ea23d1d4 100644 --- a/packages/mcpplugin/src/microsoft_teams/mcpplugin/server_plugin.py +++ b/packages/mcpplugin/src/microsoft_teams/mcpplugin/server_plugin.py @@ -13,7 +13,8 @@ from microsoft_teams.ai import Function, FunctionHandler from microsoft_teams.apps import ( DependencyMetadata, - HttpPlugin, + FastAPIAdapter, + HttpServer, Plugin, PluginBase, PluginStartEvent, @@ -40,7 +41,7 @@ class McpServerPlugin(PluginBase): """ # Dependency injection - http: Annotated[HttpPlugin, DependencyMetadata()] + http_server: Annotated[HttpServer, DependencyMetadata()] def __init__(self, name: str = "teams-mcp-server", path: str = "/mcp", logger: logging.Logger | None = None): """ @@ -158,10 +159,13 @@ async def on_start(self, event: PluginStartEvent) -> None: return try: + adapter = self.http_server.adapter + if not isinstance(adapter, FastAPIAdapter): + raise RuntimeError("McpServerPlugin requires FastAPIAdapter. Custom adapters are not supported.") + # We mount the mcp server as a separate app at self.path mcp_http_app = self.mcp_server.http_app(path=self.path, transport="http") - self.http.lifespans.append(mcp_http_app.lifespan) - self.http.app.mount("/", mcp_http_app) + adapter.app.mount("/", mcp_http_app) self._mounted = True diff --git a/uv.lock b/uv.lock index 09da5283..1bbbec0a 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,7 @@ members = [ "dialogs", "echo", "graph", + "http-adapters", "mcp-client", "mcp-server", "meetings", @@ -1172,6 +1173,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, ] +[[package]] +name = "http-adapters" +version = "0.1.0" +source = { virtual = "examples/http-adapters" } +dependencies = [ + { name = "dotenv" }, + { name = "httptools" }, + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-apps" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "httptools" }, + { name = "microsoft-teams-api", editable = "packages/api" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + [[package]] name = "httpcore" version = "1.0.9" From 89da9ae6e4fc2e3f3aad6f631e574f02de0bfcef Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 10 Mar 2026 08:58:15 -0700 Subject: [PATCH 2/2] Fix python version --- examples/http-adapters/pyproject.toml | 2 +- examples/proactive-messaging/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/http-adapters/pyproject.toml b/examples/http-adapters/pyproject.toml index 90955fbe..e8bbacd2 100644 --- a/examples/http-adapters/pyproject.toml +++ b/examples/http-adapters/pyproject.toml @@ -3,7 +3,7 @@ name = "http-adapters" version = "0.1.0" description = "Examples showing custom HttpServerAdapter and non-managed server patterns" readme = "README.md" -requires-python = ">=3.12,<3.14" +requires-python = ">=3.12,<3.15" dependencies = [ "dotenv>=0.9.9", "microsoft-teams-apps", diff --git a/examples/proactive-messaging/pyproject.toml b/examples/proactive-messaging/pyproject.toml index 437354e7..800f9712 100644 --- a/examples/proactive-messaging/pyproject.toml +++ b/examples/proactive-messaging/pyproject.toml @@ -3,7 +3,7 @@ name = "proactive-messaging" version = "0.1.0" description = "Example showing proactive messaging without running a server" readme = "README.md" -requires-python = ">=3.12,<3.14" +requires-python = ">=3.12,<3.15" dependencies = [ "dotenv>=0.9.9", "microsoft-teams-apps",