diff --git a/src/a2a/server/routes/common.py b/src/a2a/server/routes/common.py index c46847488..d640865f7 100644 --- a/src/a2a/server/routes/common.py +++ b/src/a2a/server/routes/common.py @@ -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, diff --git a/src/a2a/utils/version_validator.py b/src/a2a/utils/version_validator.py index 4a776c27e..f1dc0dd2d 100644 --- a/src/a2a/utils/version_validator.py +++ b/src/a2a/utils/version_validator.py @@ -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. @@ -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 @@ -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)): diff --git a/tests/integration/test_version_header.py b/tests/integration/test_version_header.py index 1e6367097..ae500007a 100644 --- a/tests/integration/test_version_header.py +++ b/tests/integration/test_version_header.py @@ -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 @@ -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() @@ -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: @@ -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() @@ -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: diff --git a/tests/server/routes/test_common.py b/tests/server/routes/test_common.py index e926c9177..629111417 100644 --- a/tests/server/routes/test_common.py +++ b/tests/server/routes/test_common.py @@ -2,7 +2,7 @@ import pytest -from starlette.datastructures import Headers +from starlette.datastructures import Headers, QueryParams try: @@ -17,6 +17,7 @@ DefaultServerCallContextBuilder, StarletteUser, ) +from a2a.utils import constants # --- StarletteUser Tests --- @@ -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 @@ -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) diff --git a/tests/utils/test_version_validation.py b/tests/utils/test_version_validation.py index 90147022e..95e44debb 100644 --- a/tests/utils/test_version_validation.py +++ b/tests/utils/test_version_validation.py @@ -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 @@ -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( @@ -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: