From 5c7afa5ff7965870bf9517538fde91b3c3846c77 Mon Sep 17 00:00:00 2001 From: dip2025a Date: Sun, 17 May 2026 21:36:34 -0700 Subject: [PATCH 1/5] ucp version data format correction in samples --- rest/python/server/dependencies.py | 62 +++++++++++++++++++++--------- rest/python/server/exceptions.py | 23 +++++++++++ rest/python/server/ucp_version.py | 54 ++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 rest/python/server/ucp_version.py diff --git a/rest/python/server/dependencies.py b/rest/python/server/dependencies.py index ebb49355..d1df960e 100644 --- a/rest/python/server/dependencies.py +++ b/rest/python/server/dependencies.py @@ -28,6 +28,7 @@ import config import db +from exceptions import UcpVersionError from fastapi import Depends from fastapi import Header from fastapi import HTTPException @@ -36,6 +37,7 @@ from services.checkout_service import CheckoutService from services.fulfillment_service import FulfillmentService from sqlalchemy.ext.asyncio import AsyncSession +from ucp_version import parse_ucp_version class CommonHeaders(BaseModel): @@ -63,39 +65,61 @@ async def common_headers( ) +def _version_error_detail(code: str, message: str) -> dict: + """Build a UCP-shaped error detail payload for version failures.""" + return { + "status": "error", + "errors": [ + { + "code": code, + "message": message, + "severity": "critical", + } + ], + } + + async def validate_ucp_headers(ucp_agent: str): """Validate UCP headers and version negotiation.""" server_version = config.get_server_version() - agent_version = server_version # Default to server version if not specified + try: + server_date = parse_ucp_version(server_version) + except UcpVersionError as exc: + raise HTTPException( + status_code=500, detail=exc.to_detail() + ) from exc + + # Default to server version if UCP-Agent omits version=. + agent_version = server_version + agent_date = server_date # Use regex to extract version more robustly. # We look for 'version=' either at the start or after a semicolon, # allowing for whitespace. - # Matches: version="1.2.3" or version=1.2.3 + # Matches: version="2026-01-23" or version=2026-01-23 match = re.search( r"(?:^|;)\s*version=(?:\"([^\"]+)\"|([^;]+))", ucp_agent, re.IGNORECASE ) if match: # Group 1 is quoted value, Group 2 is unquoted value - agent_version = match.group(1) or match.group(2) - agent_version = agent_version.strip() - - if agent_version > server_version: + agent_version = (match.group(1) or match.group(2)).strip() + try: + agent_date = parse_ucp_version(agent_version) + except UcpVersionError as exc: + raise HTTPException( + status_code=400, detail=exc.to_detail() + ) from exc + + if agent_date > server_date: raise HTTPException( status_code=400, - detail={ - "status": "error", - "errors": [ - { - "code": "VERSION_UNSUPPORTED", - "message": ( - f"Version {agent_version} is not supported. This merchant" - f" implements version {server_version}." - ), - "severity": "critical", - } - ], - }, + detail=_version_error_detail( + "VERSION_UNSUPPORTED", + ( + f"Version {agent_version} is not supported. This merchant" + f" implements version {server_version}." + ), + ), ) diff --git a/rest/python/server/exceptions.py b/rest/python/server/exceptions.py index 6f2c32e7..892c8ff3 100644 --- a/rest/python/server/exceptions.py +++ b/rest/python/server/exceptions.py @@ -76,3 +76,26 @@ class InvalidRequestError(UcpError): def __init__(self, message: str): """Initialize InvalidRequestError.""" super().__init__(message, code="INVALID_REQUEST", status_code=400) + + +class UcpVersionError(UcpError): + """Raised when a UCP version string is invalid or unsupported.""" + + def __init__( + self, message: str, code: str = "VERSION_INVALID_FORMAT" + ): + """Initialize UcpVersionError.""" + super().__init__(message, code=code, status_code=400) + + def to_detail(self) -> dict: + """Return an error payload matching UCP REST error shape.""" + return { + "status": "error", + "errors": [ + { + "code": self.code, + "message": self.message, + "severity": "critical", + } + ], + } diff --git a/rest/python/server/ucp_version.py b/rest/python/server/ucp_version.py new file mode 100644 index 00000000..4bb57a0f --- /dev/null +++ b/rest/python/server/ucp_version.py @@ -0,0 +1,54 @@ +# 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 version string parsing (YYYY-MM-DD).""" + +import datetime +import re + +from exceptions import UcpVersionError + +_UCP_VERSION_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def parse_ucp_version(version: str) -> datetime.date: + """Parse a UCP version string in YYYY-MM-DD format. + + Args: + version: The version string to parse. + + Returns: + A datetime.date representing the version. + + Raises: + TypeError: If version is not a string. + UcpVersionError: If the string is not a valid YYYY-MM-DD calendar date. + """ + if not isinstance(version, str): + raise TypeError(f"Version must be a string, got {type(version).__name__}.") + + version = version.strip() + if not _UCP_VERSION_RE.fullmatch(version): + raise UcpVersionError( + f"Version '{version}' is invalid. Expected YYYY-MM-DD.", + code="VERSION_INVALID_FORMAT", + ) + + try: + return datetime.date.fromisoformat(version) + except ValueError as exc: + raise UcpVersionError( + f"Version '{version}' is invalid. Expected YYYY-MM-DD.", + code="VERSION_INVALID_FORMAT", + ) from exc From dd426ee8caba9b055916088dcbfb34028bc8e672 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Mon, 13 Jul 2026 12:43:18 +0000 Subject: [PATCH 2/5] style: fix linting issues --- rest/python/server/dependencies.py | 8 ++------ rest/python/server/exceptions.py | 4 +--- rest/python/server/ucp_version.py | 1 + 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/rest/python/server/dependencies.py b/rest/python/server/dependencies.py index d1df960e..80f9c54b 100644 --- a/rest/python/server/dependencies.py +++ b/rest/python/server/dependencies.py @@ -85,9 +85,7 @@ async def validate_ucp_headers(ucp_agent: str): try: server_date = parse_ucp_version(server_version) except UcpVersionError as exc: - raise HTTPException( - status_code=500, detail=exc.to_detail() - ) from exc + raise HTTPException(status_code=500, detail=exc.to_detail()) from exc # Default to server version if UCP-Agent omits version=. agent_version = server_version @@ -106,9 +104,7 @@ async def validate_ucp_headers(ucp_agent: str): try: agent_date = parse_ucp_version(agent_version) except UcpVersionError as exc: - raise HTTPException( - status_code=400, detail=exc.to_detail() - ) from exc + raise HTTPException(status_code=400, detail=exc.to_detail()) from exc if agent_date > server_date: raise HTTPException( diff --git a/rest/python/server/exceptions.py b/rest/python/server/exceptions.py index 892c8ff3..81f20326 100644 --- a/rest/python/server/exceptions.py +++ b/rest/python/server/exceptions.py @@ -81,9 +81,7 @@ def __init__(self, message: str): class UcpVersionError(UcpError): """Raised when a UCP version string is invalid or unsupported.""" - def __init__( - self, message: str, code: str = "VERSION_INVALID_FORMAT" - ): + def __init__(self, message: str, code: str = "VERSION_INVALID_FORMAT"): """Initialize UcpVersionError.""" super().__init__(message, code=code, status_code=400) diff --git a/rest/python/server/ucp_version.py b/rest/python/server/ucp_version.py index 4bb57a0f..634c6504 100644 --- a/rest/python/server/ucp_version.py +++ b/rest/python/server/ucp_version.py @@ -34,6 +34,7 @@ def parse_ucp_version(version: str) -> datetime.date: Raises: TypeError: If version is not a string. UcpVersionError: If the string is not a valid YYYY-MM-DD calendar date. + """ if not isinstance(version, str): raise TypeError(f"Version must be a string, got {type(version).__name__}.") From 7afcaee988f7fc485a6fdb40d5b20de404309df0 Mon Sep 17 00:00:00 2001 From: dipankar1415 Date: Tue, 21 Jul 2026 08:39:14 -0700 Subject: [PATCH 3/5] changes for date format for ucp version support --- rest/python/server/ucp_version.py | 2 +- rest/python/server/ucp_version_test.py | 42 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 rest/python/server/ucp_version_test.py diff --git a/rest/python/server/ucp_version.py b/rest/python/server/ucp_version.py index 634c6504..aec1e6ab 100644 --- a/rest/python/server/ucp_version.py +++ b/rest/python/server/ucp_version.py @@ -34,7 +34,7 @@ def parse_ucp_version(version: str) -> datetime.date: Raises: TypeError: If version is not a string. UcpVersionError: If the string is not a valid YYYY-MM-DD calendar date. - + No provision for other formats supported like YYYY-MM-DDTHH:MM:SSZ """ if not isinstance(version, str): raise TypeError(f"Version must be a string, got {type(version).__name__}.") diff --git a/rest/python/server/ucp_version_test.py b/rest/python/server/ucp_version_test.py new file mode 100644 index 00000000..31484b60 --- /dev/null +++ b/rest/python/server/ucp_version_test.py @@ -0,0 +1,42 @@ +"""Unit tests for UCP version parsing.""" + +import datetime +import unittest + +from exceptions import UcpVersionError +from ucp_version import parse_ucp_version + + +class UcpVersionTest(unittest.TestCase): + """Tests parse_ucp_version behavior.""" + + def test_parse_valid_date(self) -> None: + parsed = parse_ucp_version("2026-01-23") + self.assertEqual(parsed, datetime.date(2026, 1, 23)) + + def test_parse_strips_whitespace(self) -> None: + parsed = parse_ucp_version(" 2026-01-23 ") + self.assertEqual(parsed, datetime.date(2026, 1, 23)) + + def test_parse_rejects_non_string(self) -> None: + with self.assertRaises(TypeError): + parse_ucp_version(123) # type: ignore[arg-type] + + def test_parse_rejects_invalid_format(self) -> None: + with self.assertRaises(UcpVersionError) as exc: + parse_ucp_version("2026/01/23") + self.assertEqual(exc.exception.code, "VERSION_INVALID_FORMAT") + + def test_parse_rejects_invalid_calendar_date(self) -> None: + with self.assertRaises(UcpVersionError) as exc: + parse_ucp_version("2026-02-30") + self.assertEqual(exc.exception.code, "VERSION_INVALID_FORMAT") + + def test_parse_rejects_datetime_format(self) -> None: + with self.assertRaises(UcpVersionError) as exc: + parse_ucp_version("2026-01-23T10:11:12Z") + self.assertEqual(exc.exception.code, "VERSION_INVALID_FORMAT") + + +if __name__ == "__main__": + unittest.main() From 0bade5a1d1495296fc82049ed143b4f854f76a9f Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 08:05:03 +0000 Subject: [PATCH 4/5] chore: add copyright header to ucp_version_test.py --- rest/python/server/ucp_version_test.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/rest/python/server/ucp_version_test.py b/rest/python/server/ucp_version_test.py index 31484b60..df8f2fa7 100644 --- a/rest/python/server/ucp_version_test.py +++ b/rest/python/server/ucp_version_test.py @@ -1,3 +1,17 @@ +# 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. + """Unit tests for UCP version parsing.""" import datetime @@ -11,28 +25,34 @@ class UcpVersionTest(unittest.TestCase): """Tests parse_ucp_version behavior.""" def test_parse_valid_date(self) -> None: + """Test parsing a valid YYYY-MM-DD date.""" parsed = parse_ucp_version("2026-01-23") self.assertEqual(parsed, datetime.date(2026, 1, 23)) def test_parse_strips_whitespace(self) -> None: + """Test that leading/trailing whitespace is stripped before parsing.""" parsed = parse_ucp_version(" 2026-01-23 ") self.assertEqual(parsed, datetime.date(2026, 1, 23)) def test_parse_rejects_non_string(self) -> None: + """Test that non-string inputs raise TypeError.""" with self.assertRaises(TypeError): parse_ucp_version(123) # type: ignore[arg-type] def test_parse_rejects_invalid_format(self) -> None: + """Test that invalid formats raise UcpVersionError.""" with self.assertRaises(UcpVersionError) as exc: parse_ucp_version("2026/01/23") self.assertEqual(exc.exception.code, "VERSION_INVALID_FORMAT") def test_parse_rejects_invalid_calendar_date(self) -> None: + """Test that invalid calendar dates (e.g. Feb 30) raise UcpVersionError.""" with self.assertRaises(UcpVersionError) as exc: parse_ucp_version("2026-02-30") self.assertEqual(exc.exception.code, "VERSION_INVALID_FORMAT") def test_parse_rejects_datetime_format(self) -> None: + """Test that datetime formats (with time component) are rejected.""" with self.assertRaises(UcpVersionError) as exc: parse_ucp_version("2026-01-23T10:11:12Z") self.assertEqual(exc.exception.code, "VERSION_INVALID_FORMAT") From b7e3c4ac02a9928bf275410ad5d9cdbf2879e584 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 08:09:41 +0000 Subject: [PATCH 5/5] style: fix linting issues in ucp_version.py --- rest/python/server/ucp_version.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rest/python/server/ucp_version.py b/rest/python/server/ucp_version.py index aec1e6ab..74a4b208 100644 --- a/rest/python/server/ucp_version.py +++ b/rest/python/server/ucp_version.py @@ -35,6 +35,7 @@ def parse_ucp_version(version: str) -> datetime.date: TypeError: If version is not a string. UcpVersionError: If the string is not a valid YYYY-MM-DD calendar date. No provision for other formats supported like YYYY-MM-DDTHH:MM:SSZ + """ if not isinstance(version, str): raise TypeError(f"Version must be a string, got {type(version).__name__}.")