Skip to content

Commit c6e26a8

Browse files
committed
feat: introduce A2AStarletteBuilder to construct routes and serve api-catalog.json
Signed-off-by: Shingo OKAWA <[email protected]>
1 parent 2eafd48 commit c6e26a8

File tree

1 file changed

+152
-17
lines changed

1 file changed

+152
-17
lines changed

src/a2a/server/apps/starlette_app.py

Lines changed: 152 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import contextlib
22
import json
33
import logging
4+
import posixpath
45
import traceback
56

67
from abc import ABC, abstractmethod
78
from collections.abc import AsyncGenerator
89
from typing import Any
10+
from urllib.parse import urlparse, urlunparse
911

1012
from pydantic import ValidationError
1113
from sse_starlette.sse import EventSourceResponse
1214
from starlette.applications import Starlette
1315
from starlette.authentication import BaseUser
1416
from starlette.requests import Request
1517
from starlette.responses import JSONResponse, Response
16-
from starlette.routing import Route
18+
from starlette.routing import Mount, Route
19+
from typing_extensions import Self
1720

1821
from a2a.auth.user import UnauthenticatedUser
1922
from a2a.auth.user import User as A2AUser
@@ -24,6 +27,9 @@
2427
A2AError,
2528
A2ARequest,
2629
AgentCard,
30+
AgentCatalog,
31+
AgentLinkContext,
32+
AgentLinkTarget,
2733
CancelTaskRequest,
2834
GetTaskPushNotificationConfigRequest,
2935
GetTaskRequest,
@@ -460,8 +466,8 @@ class A2AStarletteRouteBuilder:
460466
461467
Note:
462468
As of 2025-05-24, this class is almost functionally equivalent to
463-
`A2AStarletteApplication`, except that its `build()` method is focused
464-
on building Starlette `Route` objects rather than constructing a full
469+
A2AStarletteApplication, except that its build() method is focused
470+
on building Starlette Route objects rather than constructing a full
465471
Starlette application.
466472
"""
467473

@@ -470,6 +476,9 @@ def __init__(
470476
agent_card: AgentCard,
471477
http_handler: RequestHandler,
472478
extended_agent_card: AgentCard | None = None,
479+
agent_card_path: str = '/.well-known/agent.json',
480+
extended_agent_card_path: str = '/agent/authenticatedExtendedCard',
481+
rpc_path: str = '/',
473482
context_builder: CallContextBuilder | None = None,
474483
):
475484
"""Initializes the A2AStarletteRouter.
@@ -480,6 +489,9 @@ def __init__(
480489
requests via http.
481490
extended_agent_card: An optional, distinct AgentCard to be served
482491
at the authenticated extended card endpoint.
492+
agent_card_path: The URL path for the agent card endpoint.
493+
rpc_path: The URL path for the A2A JSON-RPC endpoint (POST requests).
494+
extended_agent_card_path: The URL path for the authenticated extended agent card endpoint.
483495
context_builder: The CallContextBuilder used to construct the
484496
ServerCallContext passed to the http_handler. If None, no
485497
ServerCallContext is passed.
@@ -496,6 +508,9 @@ def __init__(
496508
logger.error(
497509
'AgentCard.supportsAuthenticatedExtendedCard is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.'
498510
)
511+
self.agent_card_path = agent_card_path
512+
self.extended_agent_card_path = extended_agent_card_path
513+
self.rpc_path = rpc_path
499514
self._context_builder = context_builder
500515

501516
def _generate_error_response(
@@ -762,31 +777,21 @@ async def _handle_get_authenticated_extended_agent_card(
762777
status_code=404,
763778
)
764779

765-
def build(
766-
self,
767-
agent_card_path: str = '/.well-known/agent.json',
768-
extended_agent_card_path: str = '/agent/authenticatedExtendedCard',
769-
rpc_path: str = '/',
770-
) -> list[Route]:
780+
def build(self) -> list[Route]:
771781
"""Returns the Starlette Routes for handling A2A requests.
772782
773-
Args:
774-
agent_card_path: The URL path for the agent card endpoint.
775-
rpc_path: The URL path for the A2A JSON-RPC endpoint (POST requests).
776-
extended_agent_card_path: The URL path for the authenticated extended agent card endpoint.
777-
778783
Returns:
779784
A list of Starlette Route objects.
780785
"""
781786
routes = [
782787
Route(
783-
rpc_path,
788+
self.rpc_path,
784789
self._handle_requests,
785790
methods=['POST'],
786791
name='a2a_handler',
787792
),
788793
Route(
789-
agent_card_path,
794+
self.agent_card_path,
790795
self._handle_get_agent_card,
791796
methods=['GET'],
792797
name='agent_card',
@@ -795,10 +800,140 @@ def build(
795800
if self.agent_card.supportsAuthenticatedExtendedCard:
796801
routes.append(
797802
Route(
798-
extended_agent_card_path,
803+
self.extended_agent_card_path,
799804
self._handle_get_authenticated_extended_agent_card,
800805
methods=['GET'],
801806
name='authenticated_extended_agent_card',
802807
)
803808
)
804809
return routes
810+
811+
812+
class A2AStarletteBuilder:
813+
"""Builder class for assembling a Starlette application with A2A protocol routes.
814+
815+
This class enables mounting multiple A2AStarletteRouteBuilder instances under
816+
specific paths and generates a complete Starlette application that serves them.
817+
It also collects AgentLinkContext entries and exposes them as an AgentCatalog
818+
document at the standard path /.well-known/api-catalog.json.
819+
"""
820+
821+
def __init__(self):
822+
"""Initializes an empty A2AStarletteBuilder instance.
823+
824+
This sets up the internal structure to hold multiple mounted A2A route groups
825+
and the corresponding AgentLinkContext entries for inclusion in the Agent Catalog.
826+
827+
Attributes:
828+
_mounts: A list of Starlette Mount objects representing route groups
829+
mounted at specific paths.
830+
_catalog_links: A list of AgentLinkContext instances used to generate
831+
the Agent Catalog served at /.well-known/api-catalog.json.
832+
"""
833+
self._mounts: list[Mount] = []
834+
self._catalog_links: list[AgentLinkContext] = []
835+
836+
@staticmethod
837+
def _join_url(base: str, *paths: str) -> str:
838+
"""Joins a base URL with one or more URL path fragments into a normalized absolute URL.
839+
840+
This method ensures that redundant slashes are removed between path segments,
841+
and that the resulting URL is correctly formatted.
842+
843+
Args:
844+
base: The base URL.
845+
*paths: One or more URL fragments to append to the base path.
846+
847+
Returns:
848+
A well-formed absolute URL with the joined path components.
849+
"""
850+
parsed = urlparse(base)
851+
clean_paths = [p.strip('/') for p in paths]
852+
joined_path = posixpath.join(parsed.path.rstrip('/'), *clean_paths)
853+
return urlunparse(parsed._replace(path='/' + joined_path))
854+
855+
async def _handle_get_api_catalog(self, request: Request) -> JSONResponse:
856+
"""Handles GET requests for the AgentCatalog endpoint.
857+
858+
Args:
859+
request: The incoming Starlette Request object.
860+
861+
Returns:
862+
A JSONResponse containing the AgentCatalog data.
863+
"""
864+
catalog = AgentCatalog(links=self._catalog_links)
865+
return JSONResponse(catalog.model_dump(mode='json', exclude_none=True))
866+
867+
def mount(
868+
self,
869+
path: str,
870+
route_builder: A2AStarletteRouteBuilder,
871+
) -> Self:
872+
"""Mounts routes generated by the given builder and adds metadata to the agent catalog.
873+
874+
Raises:
875+
ValueError: If a mount for the given path already exists.
876+
"""
877+
if any(
878+
posixpath.normpath(mount.path) == posixpath.normpath(path)
879+
for mount in self._mounts
880+
):
881+
raise ValueError(f'A mount for path "{path}" already exists.')
882+
if (
883+
route_builder.extended_agent_card is not None
884+
and route_builder.agent_card.url
885+
!= route_builder.extended_agent_card.url
886+
):
887+
raise ValueError(
888+
'agent_card.url and extended_agent_card.url must be the same '
889+
'if extended_agent_card is provided'
890+
)
891+
routes = route_builder.build()
892+
self._mounts.append(Mount(path, routes=routes))
893+
anchor = _join_url(
894+
route_builder.agent_card.url, path, route_builder.rpc_path
895+
)
896+
describedby = [
897+
AgentLinkTarget(
898+
href=_join_url(
899+
route_builder.agent_card.url,
900+
path,
901+
route_builder.agent_card_path,
902+
)
903+
)
904+
]
905+
if (
906+
route_builder.extended_agent_card is not None
907+
and route_builder.agent_card.agent_card.supportsAuthenticatedExtendedCard
908+
):
909+
describedby.append(
910+
AgentLinkTarget(
911+
href=_join_url(
912+
route_builder.extended_agent_card.url,
913+
path,
914+
route_builder.extended_agent_card_path,
915+
)
916+
)
917+
)
918+
self._catalog_links.append(
919+
AgentLinkContext(
920+
anchor=anchor,
921+
describedby=describedby,
922+
)
923+
)
924+
return self
925+
926+
def build(self, **kwargs: Any) -> Starlette:
927+
"""Builds and returns a Starlette application."""
928+
catalog_route = Route(
929+
'/.well-known/api-catalog.json',
930+
endpoint=self._handle_get_api_catalog,
931+
methods=['GET'],
932+
name='api_catalog',
933+
)
934+
routes = [*self._mounts, catalog_route]
935+
if 'routes' in kwargs:
936+
kwargs['routes'].extend(routes)
937+
else:
938+
kwargs['routes'] = routes
939+
return Starlette(**kwargs)

0 commit comments

Comments
 (0)