Skip to content

feat: Adding stand-alone support for RESTful API serving #297

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: restful
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions .github/actions/spelling/allow.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ JSONRPCt
Llm
POSTGRES
RUF
Tful
aconnect
adk
agentic
Expand All @@ -38,6 +39,7 @@ drivername
dunders
euo
excinfo
fernet
fetchrow
fetchval
genai
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/linter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
run: uv sync --dev
- name: Run Ruff Linter
run: uv run ruff check .
- name: Run Ruff Format Check
run: uv run ruff format --check .
- name: Run MyPy Type Checker
run: uv run mypy src
- name: Run Pyright (Pylance equivalent)
Expand Down
28 changes: 20 additions & 8 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,24 @@ jobs:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_USER: a2a
POSTGRES_PASSWORD: a2a_password
POSTGRES_DB: a2a_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: a2a_test
MYSQL_USER: a2a
MYSQL_PASSWORD: a2a_password
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -u root -proot" --health-interval=10s --health-timeout=5s --health-retries=5

strategy:
matrix:
Expand All @@ -31,19 +44,18 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Set postgres for tests
run: |
sudo apt-get update && sudo apt-get install -y postgresql-client
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d a2a_test -f ${{ github.workspace }}/docker/postgres/init.sql
export POSTGRES_TEST_DSN="postgresql+asyncpg://postgres:postgres@localhost:5432/a2a_test"
- name: Set up test environment variables
run: |
echo "POSTGRES_TEST_DSN=postgresql+asyncpg://a2a:a2a_password@localhost:5432/a2a_test" >> $GITHUB_ENV
echo "MYSQL_TEST_DSN=mysql+aiomysql://a2a:a2a_password@localhost:3306/a2a_test" >> $GITHUB_ENV

- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Add uv to PATH
run: |
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install dependencies
run: uv sync --dev --extra sql
run: uv sync --dev --extra sql --extra encryption
- name: Run tests and check coverage
run: uv run pytest --cov=a2a --cov-report=xml --cov-fail-under=89
- name: Show coverage summary in log
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## [0.2.12](https://github.com/a2aproject/a2a-python/compare/v0.2.11...v0.2.12) (2025-07-14)


### Features

* add `metadata` property to `RequestContext` ([#302](https://github.com/a2aproject/a2a-python/issues/302)) ([e781ced](https://github.com/a2aproject/a2a-python/commit/e781ced3b082ef085f9aeef02ceebb9b35c68280))
* add A2ABaseModel ([#292](https://github.com/a2aproject/a2a-python/issues/292)) ([24f2eb0](https://github.com/a2aproject/a2a-python/commit/24f2eb0947112539cbd4e493c98d0d9dadc87f05))
* add support for notification tokens in PushNotificationSender ([#266](https://github.com/a2aproject/a2a-python/issues/266)) ([75aa4ed](https://github.com/a2aproject/a2a-python/commit/75aa4ed866a6b4005e59eb000e965fb593e0888f))
* Update A2A types from specification 🤖 ([#289](https://github.com/a2aproject/a2a-python/issues/289)) ([ecb321a](https://github.com/a2aproject/a2a-python/commit/ecb321a354d691ca90b52cc39e0a397a576fd7d7))


### Bug Fixes

* add proper a2a request body documentation to Swagger UI ([#276](https://github.com/a2aproject/a2a-python/issues/276)) ([4343be9](https://github.com/a2aproject/a2a-python/commit/4343be99ad0df5eb6908867b71d55b1f7d0fafc6)), closes [#274](https://github.com/a2aproject/a2a-python/issues/274)
* Handle asyncio.cancellederror and raise to propagate back ([#293](https://github.com/a2aproject/a2a-python/issues/293)) ([9d6cb68](https://github.com/a2aproject/a2a-python/commit/9d6cb68a1619960b9c9fd8e7aa08ffb27047343f))
* Improve error handling in task creation ([#294](https://github.com/a2aproject/a2a-python/issues/294)) ([6412c75](https://github.com/a2aproject/a2a-python/commit/6412c75413e26489bd3d33f59e41b626a71807d3))
* Resolve dependency issue with sql stores ([#303](https://github.com/a2aproject/a2a-python/issues/303)) ([2126828](https://github.com/a2aproject/a2a-python/commit/2126828b5cb6291f47ca15d56c0e870950f17536))
* Send push notifications for message/send ([#298](https://github.com/a2aproject/a2a-python/issues/298)) ([0274112](https://github.com/a2aproject/a2a-python/commit/0274112bb5b077c17b344da3a65277f2ad67d38f))
* **server:** Improve event consumer error handling ([#282](https://github.com/a2aproject/a2a-python/issues/282)) ([a5786a1](https://github.com/a2aproject/a2a-python/commit/a5786a112779a21819d28e4dfee40fa11f1bb49a))

## [0.2.11](https://github.com/a2aproject/a2a-python/compare/v0.2.10...v0.2.11) (2025-07-07)


Expand Down
8 changes: 0 additions & 8 deletions docker/postgres/init.sql

This file was deleted.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ postgresql = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.0"]
mysql = ["sqlalchemy[asyncio,aiomysql]>=2.0.0"]
sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"]
sql = ["sqlalchemy[asyncio,postgresql-asyncpg,aiomysql,aiosqlite]>=2.0.0"]
encryption = ["cryptography>=43.0.0"]

[project.urls]
homepage = "https://a2aproject.github.io/A2A/"
Expand Down
2 changes: 2 additions & 0 deletions src/a2a/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
A2AClientError,
A2AClientHTTPError,
A2AClientJSONError,
A2AClientTimeoutError,
)
from a2a.client.grpc_client import A2AGrpcClient
from a2a.client.helpers import create_text_message_object
Expand All @@ -22,6 +23,7 @@
'A2AClientError',
'A2AClientHTTPError',
'A2AClientJSONError',
'A2AClientTimeoutError',
'A2AGrpcClient',
'AuthInterceptor',
'ClientCallContext',
Expand Down
8 changes: 7 additions & 1 deletion src/a2a/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
from httpx_sse import SSEError, aconnect_sse
from pydantic import ValidationError

from a2a.client.errors import A2AClientHTTPError, A2AClientJSONError
from a2a.client.errors import (
A2AClientHTTPError,
A2AClientJSONError,
A2AClientTimeoutError,
)
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
from a2a.types import (
AgentCard,
Expand Down Expand Up @@ -340,6 +344,8 @@ async def _send_request(
)
response.raise_for_status()
return response.json()
except httpx.ReadTimeout as e:
raise A2AClientTimeoutError('Client Request timed out') from e
except httpx.HTTPStatusError as e:
raise A2AClientHTTPError(e.response.status_code, str(e)) from e
except json.JSONDecodeError as e:
Expand Down
13 changes: 13 additions & 0 deletions src/a2a/client/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,16 @@ def __init__(self, message: str):
"""
self.message = message
super().__init__(f'JSON Error: {message}')


class A2AClientTimeoutError(A2AClientError):
"""Client exception for timeout errors during a request."""

def __init__(self, message: str):
"""Initializes the A2AClientTimeoutError.

Args:
message: A descriptive error message.
"""
self.message = message
super().__init__(f'Timeout Error: {message}')
9 changes: 9 additions & 0 deletions src/a2a/server/agent_execution/context.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import uuid

from typing import Any

from a2a.server.context import ServerCallContext
from a2a.types import (
InvalidParamsError,
Expand Down Expand Up @@ -134,6 +136,13 @@ def call_context(self) -> ServerCallContext | None:
"""The server call context associated with this request."""
return self._call_context

@property
def metadata(self) -> dict[str, Any]:
"""Metadata associated with the request, if available."""
if not self._params:
return {}
return self._params.metadata or {}

def _check_or_generate_task_id(self) -> None:
"""Ensures a task ID is present, generating one if necessary."""
if not self._params:
Expand Down
6 changes: 6 additions & 0 deletions src/a2a/server/apps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@
CallContextBuilder,
JSONRPCApplication,
)
from a2a.server.apps.rest import (
A2ARESTFastAPIApplication,
RESTApplication,
)


__all__ = [
'A2AFastAPIApplication',
'A2ARESTFastAPIApplication',
'A2AStarletteApplication',
'CallContextBuilder',
'JSONRPCApplication',
'RESTApplication',
]
4 changes: 4 additions & 0 deletions src/a2a/server/apps/jsonrpc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPIApplication
from a2a.server.apps.jsonrpc.jsonrpc_app import (
CallContextBuilder,
DefaultCallContextBuilder,
JSONRPCApplication,
StarletteUserProxy,
)
from a2a.server.apps.jsonrpc.starlette_app import A2AStarletteApplication

Expand All @@ -12,5 +14,7 @@
'A2AFastAPIApplication',
'A2AStarletteApplication',
'CallContextBuilder',
'DefaultCallContextBuilder',
'JSONRPCApplication',
'StarletteUserProxy',
]
10 changes: 10 additions & 0 deletions src/a2a/server/apps/rest/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""A2A REST Applications."""

from a2a.server.apps.rest.fastapi_app import A2ARESTFastAPIApplication
from a2a.server.apps.rest.rest_app import RESTApplication


__all__ = [
'A2ARESTFastAPIApplication',
'RESTApplication',
]
81 changes: 81 additions & 0 deletions src/a2a/server/apps/rest/fastapi_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import logging

from typing import Any

from fastapi import APIRouter, FastAPI, Request, Response

from a2a.server.apps.jsonrpc.jsonrpc_app import (
CallContextBuilder,
)
from a2a.server.apps.rest.rest_app import (
RESTApplication,
)
from a2a.server.request_handlers.request_handler import RequestHandler
from a2a.types import AgentCard


logger = logging.getLogger(__name__)


class A2ARESTFastAPIApplication:
"""A FastAPI application implementing the A2A protocol server REST endpoints.

Handles incoming REST requests, routes them to the appropriate
handler methods, and manages response generation including Server-Sent Events
(SSE).
"""

def __init__(
self,
agent_card: AgentCard,
http_handler: RequestHandler,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this param should be called request_handler, right? As it is the base request handler, there's nothing related to HTTP.

context_builder: CallContextBuilder | None = None,
):
"""Initializes the A2ARESTFastAPIApplication.

Args:
agent_card: The AgentCard describing the agent's capabilities.
http_handler: The handler instance responsible for processing A2A
requests via http.
extended_agent_card: An optional, distinct AgentCard to be served
at the authenticated extended card endpoint.
context_builder: The CallContextBuilder used to construct the
ServerCallContext passed to the http_handler. If None, no
ServerCallContext is passed.
"""
self._handler = RESTApplication(
agent_card=agent_card,
http_handler=http_handler,
context_builder=context_builder,
)

def build(
self,
agent_card_url: str = '/.well-known/agent.json',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
agent_card_url: str = '/.well-known/agent.json',
agent_card_url: str = AGENT_CARD_WELL_KNOWN_PATH,

Also add import:

from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH

rpc_url: str = '',
**kwargs: Any,
) -> FastAPI:
"""Builds and returns the FastAPI application instance.

Args:
agent_card_url: The URL for the agent card endpoint.
rpc_url: The URL for the A2A JSON-RPC endpoint.
extended_agent_card_url: The URL for the authenticated extended agent card endpoint.
**kwargs: Additional keyword arguments to pass to the FastAPI constructor.

Returns:
A configured FastAPI application instance.
"""
app = FastAPI(**kwargs)
router = APIRouter()
for route, callback in self._handler.routes().items():
router.add_api_route(
f'{rpc_url}{route[0]}', callback, methods=[route[1]]
)

@router.get(f'{rpc_url}{agent_card_url}')
async def get_agent_card(request: Request) -> Response:
return await self._handler._handle_get_agent_card(request)

app.include_router(router)
return app
Loading