diff --git a/src/fabric_cli/client/fab_api_client.py b/src/fabric_cli/client/fab_api_client.py index b1de7784..b4344b22 100644 --- a/src/fabric_cli/client/fab_api_client.py +++ b/src/fabric_cli/client/fab_api_client.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import json +import os import platform import re import time @@ -96,9 +97,10 @@ def do_request( from fabric_cli.core.fab_context import Context as FabContext ctxt_cmd = FabContext().command + headers = { "Authorization": "Bearer " + str(token), - "User-Agent": f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} ({ctxt_cmd}; {platform.system()}; {platform.machine()}; {platform.release()})", + "User-Agent": _build_user_agent(ctxt_cmd), } if files is None: @@ -279,6 +281,54 @@ def _handle_successful_response(args: Namespace, response: ApiResponse) -> ApiRe return response +def _build_user_agent(ctxt_cmd: str) -> str: + """Build the User-Agent header for API requests. + + Example: + ms-fabric-cli/1.0.0 (create; Windows/10; Python/3.10.2) host-app/ado/2.0.0 + """ + user_agent = f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} ({ctxt_cmd}; {platform.system()}/{platform.release()}; Python/{platform.python_version()})" + host_app = _get_host_app() + if host_app: + user_agent += host_app + + return user_agent + + +def _get_host_app() -> str: + """Get the HostApp suffix for the User-Agent header based on environment variables. + + Returns an empty string if the environment variable is not set or has an invalid value. + """ + _host_app_in_env = os.environ.get(fab_constant.FAB_HOST_APP_ENV_VAR) + if not _host_app_in_env: + return "" + + host_app_name = next( + ( + allowed_app + for allowed_app in fab_constant.ALLOWED_FAB_HOST_APP_VALUES + if _host_app_in_env.lower() == allowed_app.lower() + ), + None, + ) + + if not host_app_name: + return "" + + host_app = f" host-app/{host_app_name.lower()}" + + # Check for optional version + host_app_version = os.environ.get(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR) + + # validate host_app_version format is a valid version (e.g., 1.0.0) + if host_app_version and re.match( + r"^\d+(\.\d+){0,2}(-[a-zA-Z0-9\.-]+)?$", host_app_version + ): + host_app += f"/{host_app_version}" + return host_app + + def _print_response_details(response: ApiResponse) -> None: response_details = dict( { diff --git a/src/fabric_cli/core/fab_constant.py b/src/fabric_cli/core/fab_constant.py index a25bf5e2..84dfa159 100644 --- a/src/fabric_cli/core/fab_constant.py +++ b/src/fabric_cli/core/fab_constant.py @@ -64,6 +64,9 @@ IDENTITY_TYPE: ["user", "service_principal", "managed_identity"], } +FAB_HOST_APP_ENV_VAR = "FAB_HOST_APP" +FAB_HOST_APP_VERSION_ENV_VAR = "FAB_HOST_APP_VERSION" + # Other constants FAB_CAPACITY_NAME_NONE = "none" FAB_DEFAULT_OPEN_EXPERIENCE_FABRIC = "fabric-developer" @@ -319,6 +322,12 @@ "folderId", } +################################################ +### Only allowed for modification by CLI team ## + +ALLOWED_FAB_HOST_APP_VALUES = ["Fabric-AzureDevops-Extension"] +################################################ + # Item set constants ITEM_QUERY_DEFINITION = "definition" ITEM_QUERY_PROPERTIES = "properties" diff --git a/tests/test_core/test_fab_api_client.py b/tests/test_core/test_fab_api_client.py index b19a592d..8817fbf1 100644 --- a/tests/test_core/test_fab_api_client.py +++ b/tests/test_core/test_fab_api_client.py @@ -8,6 +8,7 @@ import pytest from fabric_cli.client.fab_api_client import ( + _get_host_app, _transform_workspace_url_for_private_link_if_needed, do_request, ) @@ -309,6 +310,180 @@ def __init__(self): assert "ErrorCode" == excinfo.value.status_code +@pytest.mark.parametrize( + "host_app_env, host_app_version_env, expected_suffix", + [ + ( + "Fabric-AzureDevops-Extension", + None, + " host-app/fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "1.2.0", + " host-app/fabric-azuredevops-extension/1.2.0", + ), + ( + "fabric-azuredevops-extension", + "1.2.0", + " host-app/fabric-azuredevops-extension/1.2.0", + ), + ("Invalid-App", "1.0.0", ""), + ("", None, ""), + (None, None, ""), + ( + "Fabric-AzureDevops-Extension", + "1.2.0.4", # Invalid format + " host-app/fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "1.2.a", # Invalid format + " host-app/fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "a.b.c", # Invalid format + " host-app/fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "1", # valid format + " host-app/fabric-azuredevops-extension/1", + ), + ( + "Fabric-AzureDevops-Extension", + "1.2", # valid format + " host-app/fabric-azuredevops-extension/1.2", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0", # valid format + " host-app/fabric-azuredevops-extension/1.0.0", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0-rc.1", # valid format + " host-app/fabric-azuredevops-extension/1.0.0-rc.1", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0-alpha", # valid format + " host-app/fabric-azuredevops-extension/1.0.0-alpha", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0-beta", # valid format + " host-app/fabric-azuredevops-extension/1.0.0-beta", + ), + ], +) +def test_get_host_app(host_app_env, host_app_version_env, expected_suffix, monkeypatch): + """Test the _get_host_app helper function.""" + if host_app_env is not None: + monkeypatch.setenv(fab_constant.FAB_HOST_APP_ENV_VAR, host_app_env) + else: + monkeypatch.delenv(fab_constant.FAB_HOST_APP_ENV_VAR, raising=False) + + if host_app_version_env is not None: + monkeypatch.setenv( + fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, host_app_version_env + ) + else: + monkeypatch.delenv(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, raising=False) + + result = _get_host_app() + + assert result == expected_suffix + + @pytest.fixture() def setup_default_private_links(mock_fab_set_state_config): mock_fab_set_state_config(fab_constant.FAB_WS_PRIVATE_LINKS_ENABLED, "true") + + +@patch("platform.python_version", return_value="3.11.5") +@patch("platform.release", return_value="5.4.0") +@patch("platform.system", return_value="Linux") +@patch("requests.Session.request") +@patch("fabric_cli.core.fab_auth.FabAuth") +@patch("fabric_cli.core.fab_context.Context") +@pytest.mark.parametrize( + "host_app_env, host_app_version_env, expected_suffix", + [ + (None, None, ""), + ( + "Fabric-AzureDevops-Extension", + None, + " host-app/fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "1.2.0", + " host-app/fabric-azuredevops-extension/1.2.0", + ), + ("Invalid-App", "1.0.0", ""), + ], +) +def test_do_request_user_agent_header( + mock_context, + mock_auth, + mock_request, + mock_system, + mock_release, + mock_python_version, + host_app_env, + host_app_version_env, + expected_suffix, + monkeypatch, +): + """Test User-Agent header construction with and without host app identifier.""" + if host_app_env is not None: + monkeypatch.setenv(fab_constant.FAB_HOST_APP_ENV_VAR, host_app_env) + else: + monkeypatch.delenv(fab_constant.FAB_HOST_APP_ENV_VAR, raising=False) + + if host_app_version_env is not None: + monkeypatch.setenv( + fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, host_app_version_env + ) + else: + monkeypatch.delenv(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, raising=False) + + # Configure mocks + mock_auth.return_value.get_access_token.return_value = "dummy-token" + mock_context.return_value.command = "test-command" + + class DummyResponse: + status_code = 200 + text = "{}" + content = b"{}" + headers = {} + + mock_request.return_value = DummyResponse() + + dummy_args = Namespace( + uri="items", + method="get", + audience=None, + headers=None, + wait=False, + raw_response=True, + request_params={}, + json_file=None, + ) + + do_request(dummy_args) + + # Verify the User-Agent header from the actual request call + call_kwargs = mock_request.call_args.kwargs + headers = call_kwargs["headers"] + user_agent = headers["User-Agent"] + + base_user_agent = ( + f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} " + f"(test-command; Linux/5.4.0; Python/3.11.5)" + ) + expected_user_agent = base_user_agent + expected_suffix + + assert user_agent == expected_user_agent