Skip to content
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
1 change: 1 addition & 0 deletions src/a2a/server/routes/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def build(self, request: Request) -> ServerCallContext:
if 'auth' in request.scope:
state['auth'] = request.auth
state['headers'] = dict(request.headers)
state['query_params'] = dict(request.query_params)
return ServerCallContext(
user=self.build_user(request),
state=state,
Expand Down
14 changes: 11 additions & 3 deletions src/a2a/utils/version_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@


def validate_version(expected_version: str) -> Callable[[F], F]:
"""Decorator that validates the A2A-Version header in the request context.
"""Decorator that validates the A2A-Version in the request context.

The header name is defined by `constants.VERSION_HEADER` ('A2A-Version').
If the header is missing or empty, it is interpreted as `constants.PROTOCOL_VERSION_0_3` ('0.3').
The version is read from the header first, then the query parameters.
If both are missing or empty, it is interpreted as
`constants.PROTOCOL_VERSION_0_3` ('0.3').
If the version in the header does not match the `expected_version` (major and minor parts),
a `VersionNotSupportedError` is raised. Patch version is ignored.

Expand Down Expand Up @@ -71,6 +73,9 @@ def _get_actual_version(
actual_version = headers.get(
constants.VERSION_HEADER
) or headers.get(constants.VERSION_HEADER.lower())
if not actual_version:
query_params = context.state.get('query_params', {})
actual_version = query_params.get(constants.VERSION_HEADER)

if not actual_version:
return constants.PROTOCOL_VERSION_0_3
Expand All @@ -87,7 +92,10 @@ def _is_version_compatible(actual: str) -> bool:
except InvalidVersion:
return False
else:
return actual_v.major == expected_v.major
return (
actual_v.major == expected_v.major
and actual_v.minor == expected_v.minor
)

if inspect.isasyncgenfunction(inspect.unwrap(func)):

Expand Down
39 changes: 26 additions & 13 deletions tests/integration/test_version_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,22 +82,31 @@ def client(test_app):
@pytest.mark.parametrize('endpoint_ver', ['0.3', '1.0'])
@pytest.mark.parametrize('is_streaming', [False, True])
@pytest.mark.parametrize(
'header_val, should_succeed',
'header_val, query_val, should_succeed',
[
(None, '0.3'),
('0.3', '0.3'),
('1.0', '1.0'),
('1.2', '1.0'),
('2', 'none'),
('INVALID', 'none'),
(None, None, '0.3'),
('0.3', None, '0.3'),
('1.0', None, '1.0'),
('1.2', None, 'none'),
('2', None, 'none'),
('INVALID', None, 'none'),
(None, '1.0', '1.0'),
('0.3', '1.0', '0.3'),
],
)
def test_version_header_integration(
client, transport, endpoint_ver, is_streaming, header_val, should_succeed
def test_version_transport_integration(
client,
transport,
endpoint_ver,
is_streaming,
header_val,
query_val,
should_succeed,
):
headers = {}
if header_val is not None:
headers[VERSION_HEADER] = header_val
params = {VERSION_HEADER: query_val} if query_val is not None else None

expect_success = endpoint_ver == should_succeed

Expand Down Expand Up @@ -131,7 +140,7 @@ def test_version_header_integration(
if is_streaming:
headers['Accept'] = 'text/event-stream'
with client.stream(
'POST', url, json=payload, headers=headers
'POST', url, json=payload, headers=headers, params=params
) as response:
response.read()

Expand All @@ -140,7 +149,9 @@ def test_version_header_integration(
else:
assert response.status_code == 400, response.text
else:
response = client.post(url, json=payload, headers=headers)
response = client.post(
url, json=payload, headers=headers, params=params
)
if expect_success:
assert response.status_code == 200, response.text
else:
Expand Down Expand Up @@ -180,7 +191,7 @@ def test_version_header_integration(
if is_streaming:
headers['Accept'] = 'text/event-stream'
with client.stream(
'POST', url, json=payload, headers=headers
'POST', url, json=payload, headers=headers, params=params
) as response:
response.read()

Expand All @@ -193,7 +204,9 @@ def test_version_header_integration(
assert response.status_code == 200
assert 'error' in response.text.lower(), response.text
else:
response = client.post(url, json=payload, headers=headers)
response = client.post(
url, json=payload, headers=headers, params=params
)
assert response.status_code == 200, response.text
resp_data = response.json()
if expect_success:
Expand Down
13 changes: 11 additions & 2 deletions tests/server/routes/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest

from starlette.datastructures import Headers
from starlette.datastructures import Headers, QueryParams


try:
Expand All @@ -17,6 +17,7 @@
DefaultServerCallContextBuilder,
StarletteUser,
)
from a2a.utils import constants


# --- StarletteUser Tests ---
Expand Down Expand Up @@ -52,10 +53,11 @@ def test_user_name_raises_attribute_error(self):
# --- default_user_builder Tests ---


def _make_mock_request(scope=None, headers=None):
def _make_mock_request(scope=None, headers=None, query_params=None):
request = MagicMock()
request.scope = scope or {}
request.headers = Headers(headers or {})
request.query_params = QueryParams(query_params or {})
return request


Expand Down Expand Up @@ -128,6 +130,13 @@ def test_headers_captured_in_state(self):
assert ctx.state['headers']['x-custom'] == 'value'
assert ctx.state['headers']['authorization'] == 'Bearer tok'

def test_query_params_captured_in_state(self):
request = _make_mock_request(
query_params={constants.VERSION_HEADER: '1.0'}
)
ctx = DefaultServerCallContextBuilder().build(request)
assert ctx.state['query_params'][constants.VERSION_HEADER] == '1.0'

def test_requested_extensions_single(self):
request = _make_mock_request(headers={HTTP_EXTENSION_HEADER: 'foo'})
ctx = DefaultServerCallContextBuilder().build(request)
Expand Down
37 changes: 33 additions & 4 deletions tests/utils/test_version_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ async def test_validate_version_no_context():


@pytest.mark.asyncio
async def test_validate_version_ignore_minor_patch():
async def test_validate_version_ignores_patch_but_requires_matching_minor():
handler = TestHandler()

# 1.0.1 should match 1.0
Expand All @@ -127,12 +127,12 @@ async def test_validate_version_ignore_minor_patch():
result = await handler.async_method(None, context_zero_patch)
assert result == 'success'

# 1.1.0 should match 1.0
# 1.1.0 should NOT match 1.0
context_diff_minor = ServerCallContext(
state={'headers': {constants.VERSION_HEADER: '1.1.0'}}
)
result = await handler.async_method(None, context_diff_minor)
assert result == 'success'
with pytest.raises(VersionNotSupportedError):
await handler.async_method(None, context_diff_minor)

# 2.0.0 should NOT match 1.0
context_diff_major = ServerCallContext(
Expand All @@ -142,6 +142,35 @@ async def test_validate_version_ignore_minor_patch():
await handler.async_method(None, context_diff_major)


@pytest.mark.asyncio
async def test_validate_version_uses_query_parameter_when_header_missing():
handler = TestHandler()
context = ServerCallContext(
state={
'headers': {},
'query_params': {constants.VERSION_HEADER: '1.0'},
}
)

result = await handler.async_method(None, context)

assert result == 'success'


@pytest.mark.asyncio
async def test_validate_version_prefers_header_over_query_parameter():
handler = TestHandler()
context = ServerCallContext(
state={
'headers': {constants.VERSION_HEADER: '0.3'},
'query_params': {constants.VERSION_HEADER: '1.0'},
}
)

with pytest.raises(VersionNotSupportedError):
await handler.async_method(None, context)


@pytest.mark.asyncio
async def test_validate_version_handler_expects_patch():
class PatchHandler:
Expand Down
Loading