From 24ca5043c607f0d75ae1a1988d73396b28562457 Mon Sep 17 00:00:00 2001 From: ynachiket Date: Thu, 23 Apr 2026 11:37:07 -0700 Subject: [PATCH] fix(client): resolve REST endpoint from discovery profile The happy path client was ignoring the `endpoint` field from the `/.well-known/ucp` discovery response and instead hardcoding all API paths against the initial `--server_url`. Per the UCP spec (Endpoint Resolution section): > "The endpoint field provides the base URL for API calls. OpenAPI > paths are appended to this endpoint to form the complete URL." This works by coincidence when the merchant serves the API at its domain root (e.g. `http://localhost:8182`), but breaks when the merchant mounts the API under a path prefix (e.g. `https://merchant.com/buy/v1`). In that case the client would hit `https://merchant.com/checkout-sessions` instead of the correct `https://merchant.com/buy/v1/checkout-sessions`, resulting in 403/404. This commit: - Adds endpoint_resolution.py with the resolve_rest_endpoint() helper - Uses the resolved endpoint to recreate the httpx client base_url - Updates exported curl URLs to reflect the resolved endpoint - Falls back to --server_url if no endpoint is found in discovery - Adds 9 unit tests covering path-prefixed, root-level, multi-transport, and missing/empty endpoint scenarios Made-with: Cursor --- .../client/flower_shop/endpoint_resolution.py | 48 +++++ .../flower_shop/simple_happy_path_client.py | 23 ++- .../simple_happy_path_client_test.py | 176 ++++++++++++++++++ 3 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 rest/python/client/flower_shop/endpoint_resolution.py create mode 100644 rest/python/client/flower_shop/simple_happy_path_client_test.py diff --git a/rest/python/client/flower_shop/endpoint_resolution.py b/rest/python/client/flower_shop/endpoint_resolution.py new file mode 100644 index 0000000..d1a863d --- /dev/null +++ b/rest/python/client/flower_shop/endpoint_resolution.py @@ -0,0 +1,48 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""UCP endpoint resolution utilities. + +Implements the Endpoint Resolution logic from the UCP specification: +https://ucp.dev/2026-04-08/specification/overview#endpoint-resolution + + "The endpoint field provides the base URL for API calls. OpenAPI paths + are appended to this endpoint to form the complete URL." +""" + + +def resolve_rest_endpoint(discovery_data: dict) -> str | None: + """Extract the REST endpoint from a UCP discovery response. + + Looks up ``services["dev.ucp.shopping"]`` in the discovery payload, + finds the entry with ``transport == "rest"``, and returns its + ``endpoint`` value. All subsequent API paths (e.g. + ``/checkout-sessions``) should be appended to this base URL. + + Args: + discovery_data: Parsed JSON body from ``GET /.well-known/ucp``. + + Returns: + The resolved endpoint URL, or ``None`` if no REST service was found. + + """ + shopping_services = discovery_data.get("services", {}).get( + "dev.ucp.shopping", [] + ) + rest_service = next( + (s for s in shopping_services if s.get("transport") == "rest"), None + ) + if rest_service and rest_service.get("endpoint"): + return rest_service["endpoint"] + return None diff --git a/rest/python/client/flower_shop/simple_happy_path_client.py b/rest/python/client/flower_shop/simple_happy_path_client.py index 6859332..10826aa 100644 --- a/rest/python/client/flower_shop/simple_happy_path_client.py +++ b/rest/python/client/flower_shop/simple_happy_path_client.py @@ -43,6 +43,8 @@ from ucp_sdk.models.schemas.shopping.types import line_item_create_request from ucp_sdk.models.schemas.shopping.types import line_item_update_request +from endpoint_resolution import resolve_rest_endpoint + def get_headers() -> dict[str, str]: """Generate necessary headers for UCP requests.""" @@ -237,6 +239,13 @@ def main() -> None: discovery_data = response.json() + api_base_url = resolve_rest_endpoint(discovery_data) or args.server_url + logger.info("Resolved REST endpoint: %s", api_base_url) + if api_base_url != args.server_url: + client.close() + client = httpx.Client(base_url=api_base_url) + global_replacements[api_base_url] = "ENDPOINT" + supported_handlers = [] for handlers in discovery_data.get("payment_handlers", {}).values(): supported_handlers.extend(handlers) @@ -324,7 +333,7 @@ def main() -> None: log_interaction( args.export_requests_to, "POST", - f"{args.server_url}{url}", + f"{api_base_url}{url}", headers, json_body, response, @@ -414,7 +423,7 @@ def main() -> None: log_interaction( args.export_requests_to, "PUT", - f"{args.server_url}{url}", + f"{api_base_url}{url}", headers, json_body, response, @@ -505,7 +514,7 @@ def main() -> None: log_interaction( args.export_requests_to, "PUT", - f"{args.server_url}{url}", + f"{api_base_url}{url}", headers, json_body, response, @@ -646,7 +655,7 @@ def main() -> None: log_interaction( args.export_requests_to, "PUT", - f"{args.server_url}{url}", + f"{api_base_url}{url}", headers, trigger_payload, response, @@ -704,7 +713,7 @@ def main() -> None: log_interaction( args.export_requests_to, "PUT", - f"{args.server_url}{url}", + f"{api_base_url}{url}", headers, payload, response, @@ -762,7 +771,7 @@ def main() -> None: log_interaction( args.export_requests_to, "PUT", - f"{args.server_url}{url}", + f"{api_base_url}{url}", headers, payload, response, @@ -864,7 +873,7 @@ def main() -> None: log_interaction( args.export_requests_to, "POST", - f"{args.server_url}{url}", + f"{api_base_url}{url}", headers, final_payload, response, diff --git a/rest/python/client/flower_shop/simple_happy_path_client_test.py b/rest/python/client/flower_shop/simple_happy_path_client_test.py new file mode 100644 index 0000000..2e312db --- /dev/null +++ b/rest/python/client/flower_shop/simple_happy_path_client_test.py @@ -0,0 +1,176 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for endpoint resolution in the happy path client. + +Verifies that resolve_rest_endpoint correctly extracts the REST endpoint +from a UCP discovery response, per the spec's Endpoint Resolution section: +https://ucp.dev/2026-04-08/specification/overview#endpoint-resolution +""" + +import unittest + +from endpoint_resolution import resolve_rest_endpoint + + +class ResolveRestEndpointTest(unittest.TestCase): + """Tests for resolve_rest_endpoint.""" + + def test_returns_endpoint_with_path_prefix(self): + """Merchant mounts API under a path prefix (the bug scenario).""" + discovery = { + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "transport": "rest", + "endpoint": "https://merchant.example.com/buy/v1", + "schema": "https://ucp.dev/2026-04-08/services/shopping/rest.openapi.json", + } + ] + } + } + self.assertEqual( + resolve_rest_endpoint(discovery), + "https://merchant.example.com/buy/v1", + ) + + def test_returns_endpoint_at_root(self): + """Merchant serves API at domain root (sample server scenario).""" + discovery = { + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "transport": "rest", + "endpoint": "http://localhost:8182", + "schema": "https://ucp.dev/2026-04-08/services/shopping/rest.openapi.json", + } + ] + } + } + self.assertEqual( + resolve_rest_endpoint(discovery), + "http://localhost:8182", + ) + + def test_selects_rest_transport_among_multiple(self): + """Discovery lists multiple transports; only REST is selected.""" + discovery = { + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "transport": "mcp", + "endpoint": "https://merchant.example.com/ucp/mcp", + "schema": "https://ucp.dev/2026-04-08/services/shopping/mcp.openrpc.json", + }, + { + "version": "2026-04-08", + "transport": "rest", + "endpoint": "https://merchant.example.com/api/v2", + "schema": "https://ucp.dev/2026-04-08/services/shopping/rest.openapi.json", + }, + { + "version": "2026-04-08", + "transport": "a2a", + "endpoint": "https://merchant.example.com/.well-known/agent-card.json", + }, + ] + } + } + self.assertEqual( + resolve_rest_endpoint(discovery), + "https://merchant.example.com/api/v2", + ) + + def test_returns_none_when_no_services(self): + """Discovery response has no services key at all.""" + discovery = {"payment_handlers": {}} + self.assertIsNone(resolve_rest_endpoint(discovery)) + + def test_returns_none_when_services_empty(self): + """Discovery response has empty services.""" + discovery = {"services": {}} + self.assertIsNone(resolve_rest_endpoint(discovery)) + + def test_returns_none_when_no_rest_transport(self): + """Shopping service exists but only non-REST transports are listed.""" + discovery = { + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "transport": "mcp", + "endpoint": "https://merchant.example.com/ucp/mcp", + "schema": "https://ucp.dev/2026-04-08/services/shopping/mcp.openrpc.json", + } + ] + } + } + self.assertIsNone(resolve_rest_endpoint(discovery)) + + def test_returns_none_when_rest_has_no_endpoint(self): + """REST transport exists but endpoint field is missing.""" + discovery = { + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "transport": "rest", + "schema": "https://ucp.dev/2026-04-08/services/shopping/rest.openapi.json", + } + ] + } + } + self.assertIsNone(resolve_rest_endpoint(discovery)) + + def test_returns_none_when_endpoint_is_empty_string(self): + """REST transport exists but endpoint is an empty string.""" + discovery = { + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "transport": "rest", + "endpoint": "", + "schema": "https://ucp.dev/2026-04-08/services/shopping/rest.openapi.json", + } + ] + } + } + self.assertIsNone(resolve_rest_endpoint(discovery)) + + def test_handles_deeply_nested_path_prefix(self): + """Endpoint has multiple path segments.""" + discovery = { + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-04-08", + "transport": "rest", + "endpoint": "https://api.merchant.com/ucp/shopping/v1", + "schema": "https://ucp.dev/2026-04-08/services/shopping/rest.openapi.json", + } + ] + } + } + self.assertEqual( + resolve_rest_endpoint(discovery), + "https://api.merchant.com/ucp/shopping/v1", + ) + + +if __name__ == "__main__": + unittest.main()