Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions packages/a2aprotocol/src/microsoft_teams/a2a/server/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
DependencyMetadata,
EventMetadata,
FastAPIAdapter,
HttpServerAdapter,
HttpServer,
LoggerDependencyOptions,
Plugin,
PluginBase,
Expand All @@ -31,7 +31,7 @@
@Plugin(name="a2a", version="0.3.7", description="A2A Server Plugin")
class A2APlugin(PluginBase):
logger: Annotated[Logger, LoggerDependencyOptions()]
http_server_adapter: Annotated[HttpServerAdapter, DependencyMetadata()]
http_server: Annotated[HttpServer, DependencyMetadata()]

emit: Annotated[Callable[[str, A2AMessageEvent], Awaitable[None]], EventMetadata(name="custom")]

Expand Down Expand Up @@ -77,9 +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}")

if not isinstance(self.http_server_adapter, FastAPIAdapter):
adapter = self.http_server.adapter
if not isinstance(adapter, FastAPIAdapter):
raise RuntimeError("A2APlugin requires FastAPIAdapter. Custom adapters are not supported.")
self.http_server_adapter.app.mount(self.path, self.app)
adapter.app.mount(self.path, self.app)

def _setup_executor(self) -> AgentExecutor:
return CustomAgentExecutor(self.emit)
Expand Down
10 changes: 5 additions & 5 deletions packages/apps/src/microsoft_teams/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def __init__(self, **options: Unpack[AppOptions]):
# 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("HttpServerAdapter", providers.Object(self.server.adapter))
self.container.set_provider("HttpServer", providers.Object(self.server))

self._port: Optional[int] = None
self._initialized = False
Expand Down Expand Up @@ -248,7 +248,7 @@ async def start(self, port: Optional[int] = None) -> None:
self.log.info("Teams app started successfully")
self._events.emit("start", StartEvent(port=self._port))

tasks.append(self.server.start(self._port))
tasks.append(self.server.adapter.start(self._port))
await asyncio.gather(*tasks)

except (asyncio.CancelledError, KeyboardInterrupt):
Expand All @@ -268,7 +268,7 @@ async def stop(self) -> None:
"""Stop the Teams application."""
try:
# Stop HTTP server first
await self.server.stop()
await self.server.adapter.stop()

# Stop all plugins
for plugin in reversed(self.plugins):
Expand Down Expand Up @@ -463,7 +463,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.server.serve_static(page_path or f"/{name}", dir_path)
self.server.adapter.serve_static(page_path or f"/{name}", dir_path)

def tab(self, name: str, path: str) -> None:
"""
Expand Down Expand Up @@ -517,7 +517,7 @@ async def handler(request: HttpRequest) -> HttpResponse:
result = await func(ctx)
return HttpResponse(status=200, body=result)

self.server.register_route("POST", f"/api/functions/{endpoint_name}", handler)
self.server.adapter.register_route("POST", f"/api/functions/{endpoint_name}", handler)
return func

# Direct decoration: @app.func
Expand Down
26 changes: 4 additions & 22 deletions packages/apps/src/microsoft_teams/apps/http/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from ..auth import TokenValidator
from ..events import ActivityEvent, CoreActivity
from .adapter import HttpMethod, HttpRequest, HttpResponse, HttpRouteHandler, HttpServerAdapter
from .adapter import HttpRequest, HttpResponse, HttpServerAdapter


class HttpServer:
Expand Down Expand Up @@ -68,11 +68,11 @@ def initialize(
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._adapter.register_route("POST", "/api/messages", self.handle_request)
self._initialized = True

async def _handle_activity(self, request: HttpRequest) -> HttpResponse:
"""Handle incoming activity on POST /api/messages."""
async def handle_request(self, request: HttpRequest) -> HttpResponse:
"""Handle incoming activity request. Public so plugins (e.g. BotBuilder) can route through SDK auth."""
try:
body = request["body"]
headers = request["headers"]
Expand Down Expand Up @@ -151,21 +151,3 @@ def _format_response(self, result: Any) -> HttpResponse:
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()
12 changes: 6 additions & 6 deletions packages/apps/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ def basic_options(self, mock_logger, mock_storage):

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]
app.server.adapter.start = AsyncMock() # type: ignore[method-assign]
app.server.adapter.stop = AsyncMock() # type: ignore[method-assign]
return app

@pytest.fixture(scope="function")
Expand Down Expand Up @@ -136,7 +136,7 @@ def test_app_starts_successfully(self, basic_options):
async def test_app_lifecycle_start_stop(self, app_with_options):
"""Test basic app lifecycle: start and stop."""

# Test start — server.start is already mocked by _mock_http_server
# Test start — server.adapter.start is already mocked by _mock_http_server
start_task = asyncio.create_task(app_with_options.start(3978))
await asyncio.sleep(0.1)

Expand Down Expand Up @@ -182,8 +182,8 @@ async def on_stop(self) -> None: # noqa: D102
async def blocking_start(port):
await block.wait()

app.server.start = AsyncMock(side_effect=blocking_start) # type: ignore[method-assign]
app.server.stop = AsyncMock() # type: ignore[method-assign]
app.server.adapter.start = AsyncMock(side_effect=blocking_start) # type: ignore[method-assign]
app.server.adapter.stop = AsyncMock() # type: ignore[method-assign]

start_task = asyncio.create_task(app.start(3978))
await asyncio.sleep(0.1)
Expand Down Expand Up @@ -522,7 +522,7 @@ async def logging_middleware(ctx: ActivityContext) -> None:
async def test_func_decorator_registration(self, app_with_options: App):
"""Simple test that @app.func registers a function."""
mock_register = MagicMock()
app_with_options.server.register_route = mock_register # type: ignore[method-assign]
app_with_options.server.adapter.register_route = mock_register # type: ignore[method-assign]

async def dummy_func(ctx):
return "called"
Expand Down
32 changes: 3 additions & 29 deletions packages/apps/tests/test_http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ async def mock_handler(event):
headers={},
)

result = await server._handle_activity(request)
result = await server.handle_request(request)

assert result["status"] == 200
assert result["body"] == expected_body
Expand All @@ -102,7 +102,7 @@ async def failing_handler(event):
headers={},
)

result = await server._handle_activity(request)
result = await server.handle_request(request)

assert result["status"] == 500
mock_logger.exception.assert_called_once()
Expand All @@ -117,37 +117,11 @@ async def test_handle_activity_no_handler(self, server, mock_logger):
headers={},
)

result = await server._handle_activity(request)
result = await server.handle_request(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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,16 @@
import importlib.metadata
from logging import Logger
from types import SimpleNamespace
from typing import Annotated, Any, Callable, Dict, Optional, TypedDict, Unpack, cast
from typing import Annotated, Optional, TypedDict, Unpack, cast

from microsoft_teams.api import Credentials, InvokeResponse
from microsoft_teams.api import Credentials
from microsoft_teams.apps import (
DependencyMetadata,
EventMetadata,
HttpServerAdapter,
HttpServer,
LoggerDependencyOptions,
Plugin,
PluginBase,
)
from microsoft_teams.apps.events import ActivityEvent, CoreActivity, ErrorEvent
from microsoft_teams.apps.http import HttpRequest, HttpResponse

from botbuilder.core import (
Expand Down Expand Up @@ -53,10 +51,7 @@ class BotBuilderPlugin(PluginBase):
# Dependency injections
logger: Annotated[Logger, LoggerDependencyOptions()]
credentials: Annotated[Optional[Credentials], DependencyMetadata(optional=True)]
http_server_adapter: Annotated[HttpServerAdapter, DependencyMetadata()]

on_error_event: Annotated[Callable[[ErrorEvent], None], EventMetadata(name="error")]
on_activity_event: Annotated[Callable[[ActivityEvent], InvokeResponse[Any]], EventMetadata(name="activity")]
http_server: Annotated[HttpServer, DependencyMetadata()]

def __init__(self, **options: Unpack[BotBuilderPluginOptions]):
"""
Expand Down Expand Up @@ -94,14 +89,15 @@ async def on_init(self) -> None:

self.logger.debug("BotBuilder plugin initialized successfully")

# Register the activity route via adapter
self.http_server_adapter.register_route("POST", "/api/messages", self._handle_activity)
# Register the activity route via adapter (bypasses HttpServer's default /api/messages)
self.http_server.adapter.register_route("POST", "/api/messages", self._handle_activity)

async def _handle_activity(self, request: HttpRequest) -> HttpResponse:
"""
Pure handler for POST /api/messages.
Handler for POST /api/messages.

Processes via Bot Framework, then passes to the Teams pipeline.
Runs Bot Framework CloudAdapter auth + handler first,
then routes through HttpServer.handle_request for SDK-level JWT validation and pipeline.
"""
if not self.adapter:
raise RuntimeError("plugin not registered")
Expand All @@ -127,39 +123,8 @@ async def logic(turn_context: TurnContext) -> None:
auth_header = headers.get("authorization") or headers.get("Authorization") or ""
await self.adapter.process_activity(auth_header, activity_bf, logic)

# 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,
),
)

event_result = self.on_activity_event(ActivityEvent(body=core_activity, token=token))
result: Any = await cast(Any, event_result)

# Format response
status_code: int = 200
resp_body: Any = None
resp_dict: Dict[str, Any] = {}
if result is not None and hasattr(result, "model_dump"):
resp_dict = cast(Dict[str, Any], result.model_dump(exclude_none=True))
elif isinstance(result, dict):
resp_dict = cast(Dict[str, Any], result)

if "status" in resp_dict:
status_code = int(resp_dict.get("status", 200))
if "body" in resp_dict:
resp_body = resp_dict.get("body")

return HttpResponse(status=status_code, body=resp_body)
# Route through HttpServer for SDK auth + Teams pipeline
return await self.http_server.handle_request(request)

except Exception as err:
self.logger.error(f"Error processing activity: {err}", exc_info=True)
Expand Down
32 changes: 20 additions & 12 deletions packages/botbuilder/tests/test_botbuilder_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
from botbuilder.core import ActivityHandler, TurnContext
from botbuilder.integration.aiohttp import CloudAdapter
from botbuilder.schema import Activity
from microsoft_teams.api import Credentials, InvokeResponse
from microsoft_teams.apps.http.adapter import HttpRequest, HttpServerAdapter
from microsoft_teams.api import Credentials
from microsoft_teams.apps.http import HttpRequest, HttpResponse
from microsoft_teams.apps.http.http_server import HttpServer
from microsoft_teams.botbuilder import BotBuilderPlugin


Expand All @@ -23,27 +24,31 @@ def mock_logger(self):
return MagicMock()

@pytest.fixture
def plugin_without_adapter(self):
def mock_http_server(self):
server = MagicMock(spec=HttpServer)
server.adapter = MagicMock()
server.handle_request = AsyncMock(return_value=HttpResponse(status=200, body=None))
return server

@pytest.fixture
def plugin_without_adapter(self, mock_http_server):
plugin = BotBuilderPlugin()
plugin.credentials = MagicMock(spec=Credentials)
plugin.credentials.client_id = "abc"
plugin.credentials.client_secret = "secret"
plugin.credentials.tenant_id = "tenant-123"
plugin.http_server_adapter = MagicMock(spec=HttpServerAdapter)
plugin.http_server = mock_http_server
plugin.logger = MagicMock()
return plugin

@pytest.fixture
def plugin_with_adapter(self) -> BotBuilderPlugin:
def plugin_with_adapter(self, mock_http_server) -> BotBuilderPlugin:
adapter = MagicMock(spec=CloudAdapter)
plugin = BotBuilderPlugin(adapter=adapter)
handler = AsyncMock(spec=ActivityHandler)
plugin.handler = handler
plugin.http_server_adapter = MagicMock(spec=HttpServerAdapter)
plugin.http_server = mock_http_server
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
Expand All @@ -63,9 +68,9 @@ 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 adapter
plugin_without_adapter.http_server_adapter.register_route.assert_called_once()
call_args = plugin_without_adapter.http_server_adapter.register_route.call_args
# Should have registered route via http_server.adapter
plugin_without_adapter.http_server.adapter.register_route.assert_called_once()
call_args = plugin_without_adapter.http_server.adapter.register_route.call_args
assert call_args[0][0] == "POST"
assert call_args[0][1] == "/api/messages"

Expand Down Expand Up @@ -104,6 +109,9 @@ async def fake_process_activity(auth_header, activity, logic):
# Ensure handler called via TurnContext
plugin_with_adapter.handler.on_turn.assert_awaited()

# Should have routed through HttpServer.handle_request
plugin_with_adapter.http_server.handle_request.assert_awaited_once_with(request)

# Should return a valid HttpResponse
assert result["status"] == 200

Expand Down
Loading
Loading