Skip to content

Commit 2490a11

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

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,
@@ -472,8 +478,8 @@ class A2AStarletteRouteBuilder:
472478
473479
Note:
474480
As of 2025-05-24, this class is almost functionally equivalent to
475-
`A2AStarletteApplication`, except that its `build()` method is focused
476-
on building Starlette `Route` objects rather than constructing a full
481+
A2AStarletteApplication, except that its build() method is focused
482+
on building Starlette Route objects rather than constructing a full
477483
Starlette application.
478484
"""
479485

@@ -482,6 +488,9 @@ def __init__(
482488
agent_card: AgentCard,
483489
http_handler: RequestHandler,
484490
extended_agent_card: AgentCard | None = None,
491+
agent_card_path: str = '/.well-known/agent.json',
492+
extended_agent_card_path: str = '/agent/authenticatedExtendedCard',
493+
rpc_path: str = '/',
485494
context_builder: CallContextBuilder | None = None,
486495
):
487496
"""Initializes the A2AStarletteRouter.
@@ -492,6 +501,9 @@ def __init__(
492501
requests via http.
493502
extended_agent_card: An optional, distinct AgentCard to be served
494503
at the authenticated extended card endpoint.
504+
agent_card_path: The URL path for the agent card endpoint.
505+
rpc_path: The URL path for the A2A JSON-RPC endpoint (POST requests).
506+
extended_agent_card_path: The URL path for the authenticated extended agent card endpoint.
495507
context_builder: The CallContextBuilder used to construct the
496508
ServerCallContext passed to the http_handler. If None, no
497509
ServerCallContext is passed.
@@ -508,6 +520,9 @@ def __init__(
508520
logger.error(
509521
'AgentCard.supportsAuthenticatedExtendedCard is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.'
510522
)
523+
self.agent_card_path = agent_card_path
524+
self.extended_agent_card_path = extended_agent_card_path
525+
self.rpc_path = rpc_path
511526
self._context_builder = context_builder
512527

513528
def _generate_error_response(
@@ -774,31 +789,21 @@ async def _handle_get_authenticated_extended_agent_card(
774789
status_code=404,
775790
)
776791

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

0 commit comments

Comments
 (0)