Skip to content
Closed
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
214 changes: 197 additions & 17 deletions src/ucp_sdk/models/discovery/profile_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@

from __future__ import annotations

from typing import Literal
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, RootModel
from pydantic import AnyUrl, BaseModel, ConfigDict, Field, model_validator

from ..schemas._internal import Base_3, BusinessSchema_3, PlatformSchema_3
from ..schemas._internal import ReverseDomainName, Version


class SigningKey(BaseModel):
Expand Down Expand Up @@ -70,44 +70,224 @@ class SigningKey(BaseModel):
"""


class Base(BaseModel):
"""Base discovery profile with shared properties for all profile types.
class ServiceBinding(BaseModel):
"""Transport-specific binding information for a service.
"""

model_config = ConfigDict(
extra="allow",
)
ucp: Base_3
signing_keys: list[SigningKey] | None = None
endpoint: AnyUrl | str | None = None
schema_: AnyUrl | str | None = Field(None, alias="schema")
"""
Public keys for signature verification (JWK format). Used to verify signed responses, webhooks, and other authenticated messages from this party.
URL to JSON schema for the service transport.
"""


class PlatformProfile(Base):
"""Full discovery profile for platforms. Exposes complete service, capability, and payment handler registries.
class ServiceDescriptor(BaseModel):
"""Service metadata normalized across legacy and current schema layouts.
"""

model_config = ConfigDict(
extra="allow",
)
ucp: PlatformSchema_3 | None = None
version: Version | None = None
spec: AnyUrl | str | None = None
rest: ServiceBinding | None = None
mcp: ServiceBinding | None = None
a2a: ServiceBinding | None = None
embedded: ServiceBinding | None = None


class BusinessProfile(Base):
"""Discovery profile for businesses/merchants. Subset of platform profile with business-specific configuration.
def _normalize_service(value: Any) -> dict[str, Any]:
"""Normalize service payloads from both legacy and generated schemas."""
if isinstance(value, list):
normalized: dict[str, Any] = {}
for entry in value:
if not isinstance(entry, dict):
continue
transport = entry.get("transport")
if transport in {"rest", "mcp", "a2a", "embedded"}:
normalized[transport] = {
k: v
for k, v in entry.items()
if k in {"endpoint", "schema", "config"} and v is not None
}
# Carry shared metadata once from the first entry that has it.
if "version" in entry and "version" not in normalized:
normalized["version"] = entry["version"]
if "spec" in entry and "spec" not in normalized:
normalized["spec"] = entry["spec"]
return normalized
if isinstance(value, dict):
return value
return {}


class ServiceRegistry(BaseModel):
"""Map of service names to service descriptors.
"""

model_config = ConfigDict(
extra="allow",
)
ucp: BusinessSchema_3 | None = None
root: dict[str, ServiceDescriptor] = Field(default_factory=dict)

@model_validator(mode="before")
@classmethod
def _normalize(cls, value: Any) -> dict[str, Any]:
if isinstance(value, dict) and "root" in value:
return value
if isinstance(value, dict):
return {"root": {str(k): _normalize_service(v) for k, v in value.items()}}
return {"root": {}}


class Capability(BaseModel):
"""Capability entry for legacy discovery payloads."""

model_config = ConfigDict(
extra="allow",
)
name: str | None = None
version: Version | None = None
spec: AnyUrl | str | None = None
schema_: AnyUrl | str | None = Field(None, alias="schema")
extends: str | None = Field(
None, pattern="^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9_]*)+$"
)


def _normalize_capabilities(value: Any) -> list[dict[str, Any]]:
"""Normalize capabilities from list or dict-keyed formats."""
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
if isinstance(value, dict):
normalized: list[dict[str, Any]] = []
for capability_name, entries in value.items():
if isinstance(entries, list):
iterable = entries
else:
iterable = [entries]
for entry in iterable:
if not isinstance(entry, dict):
continue
normalized_entry = dict(entry)
normalized_entry.setdefault("name", str(capability_name))
normalized.append(normalized_entry)
return normalized
return []


class PaymentHandlerResponse(BaseModel):
"""Legacy-compatible payment handler declaration."""

model_config = ConfigDict(
extra="allow",
)
id: str | None = None
name: str | None = None
version: Version | None = None
spec: AnyUrl | str | None = None
config_schema: AnyUrl | str | None = None
instrument_schemas: list[AnyUrl | str] | None = None
config: dict[str, Any] | None = None


class PaymentProfile(BaseModel):
"""Legacy-compatible top-level payment discovery section."""

model_config = ConfigDict(
extra="allow",
)
handlers: list[PaymentHandlerResponse] | None = None


class UcpDiscoveryProfile(RootModel[PlatformProfile | BusinessProfile]):
root: PlatformProfile | BusinessProfile = Field(
..., title="UCP Discovery Profile"
class Base(BaseModel):
"""Base discovery profile with shared properties for all profile types."""

model_config = ConfigDict(
extra="allow",
)
ucp: UcpMetadata
signing_keys: list[SigningKey] | None = None
"""
Public keys for signature verification (JWK format). Used to verify signed responses, webhooks, and other authenticated messages from this party.
"""


class UcpMetadata(BaseModel):
"""Legacy-compatible UCP discovery payload."""

model_config = ConfigDict(
extra="allow",
)
version: Version
services: ServiceRegistry
capabilities: list[Capability] = Field(default_factory=list)
payment_handlers: dict[ReverseDomainName, list[dict[str, Any]]] | None = None

@model_validator(mode="before")
@classmethod
def _normalize(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
normalized = dict(value)
normalized["capabilities"] = _normalize_capabilities(
normalized.get("capabilities")
)
return normalized


class PlatformProfile(Base):
"""Full discovery profile for platforms."""


class BusinessProfile(Base):
"""Discovery profile for businesses/merchants."""


def _flatten_payment_handlers(
keyed_handlers: dict[str, Any] | None,
) -> list[dict[str, Any]]:
if not isinstance(keyed_handlers, dict):
return []
flattened: list[dict[str, Any]] = []
for handler_name, entries in keyed_handlers.items():
iterable = entries if isinstance(entries, list) else [entries]
for entry in iterable:
if not isinstance(entry, dict):
continue
normalized = dict(entry)
normalized.setdefault("name", str(handler_name))
flattened.append(normalized)
return flattened


class UcpDiscoveryProfile(Base):
payment: PaymentProfile | None = None
"""
Schema for UCP discovery profiles. Business profiles are hosted at /.well-known/ucp; platform profiles are hosted at URIs advertised in request headers.
"""

@model_validator(mode="before")
@classmethod
def _normalize(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value

# Accept prior RootModel-style payloads.
if "root" in value and isinstance(value["root"], dict):
value = dict(value["root"])
else:
value = dict(value)

ucp = value.get("ucp")
if isinstance(ucp, dict):
payment = value.get("payment")
if payment is None:
flattened = _flatten_payment_handlers(ucp.get("payment_handlers"))
if flattened:
value["payment"] = {"handlers": flattened}

return value
92 changes: 49 additions & 43 deletions src/ucp_sdk/models/schemas/_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,55 @@ class UcpCapability(RootModel[Any]):
"""


class Version(RootModel[str]):
model_config = ConfigDict(
frozen=True,
)
root: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$")
"""
UCP version in YYYY-MM-DD format.
"""


class ReverseDomainName(RootModel[str]):
model_config = ConfigDict(
frozen=True,
)
root: str = Field(..., pattern="^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9_]*)+$")
"""
Reverse-domain identifier (e.g., com.google.pay, dev.ucp.shopping.checkout)
"""


class Entity(BaseModel):
"""Shared foundation for all UCP entities.
"""

model_config = ConfigDict(
extra="allow",
)
version: Version
"""
Entity version in YYYY-MM-DD format.
"""
spec: AnyUrl | None = None
"""
URL to human-readable specification document.
"""
schema_: AnyUrl | None = Field(None, alias="schema")
"""
URL to JSON Schema defining this entity's structure and payloads.
"""
id: str | None = None
"""
Unique identifier for this entity instance. Used to disambiguate when multiple instances exist.
"""
config: dict[str, Any] | None = None
"""
Entity-specific configuration. Structure defined by each entity's schema.
"""


class Base(Entity):
model_config = ConfigDict(
extra="allow",
Expand Down Expand Up @@ -408,49 +457,6 @@ class UcpMetadata(RootModel[Any]):
"""


class Version(RootModel[str]):
root: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$")
"""
UCP version in YYYY-MM-DD format.
"""


class ReverseDomainName(RootModel[str]):
root: str = Field(..., pattern="^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9_]*)+$")
"""
Reverse-domain identifier (e.g., com.google.pay, dev.ucp.shopping.checkout)
"""


class Entity(BaseModel):
"""Shared foundation for all UCP entities.
"""

model_config = ConfigDict(
extra="allow",
)
version: Version
"""
Entity version in YYYY-MM-DD format.
"""
spec: AnyUrl | None = None
"""
URL to human-readable specification document.
"""
schema_: AnyUrl | None = Field(None, alias="schema")
"""
URL to JSON Schema defining this entity's structure and payloads.
"""
id: str | None = None
"""
Unique identifier for this entity instance. Used to disambiguate when multiple instances exist.
"""
config: dict[str, Any] | None = None
"""
Entity-specific configuration. Structure defined by each entity's schema.
"""


class Base_3(BaseModel):
"""Base UCP metadata with shared properties for all schema types.
"""
Expand Down
16 changes: 16 additions & 0 deletions src/ucp_sdk/models/schemas/shopping/ap2_mandate.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,19 @@ class Checkout(Checkout_1):
extra="allow",
)
ap2: Ap2 | None = None


class Ap2CompleteRequest(Ap2WithCheckoutMandate):
"""Legacy completion payload alias used by conformance and samples."""

model_config = ConfigDict(
extra="allow",
)


class CheckoutResponseWithAp2(Checkout):
"""Legacy response alias used by samples."""

model_config = ConfigDict(
extra="allow",
)
3 changes: 3 additions & 0 deletions src/ucp_sdk/models/schemas/shopping/buyer_consent_resp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .buyer_consent import Buyer, Checkout, Consent

__all__ = ["Buyer", "Checkout", "Consent"]
3 changes: 3 additions & 0 deletions src/ucp_sdk/models/schemas/shopping/checkout_create_req.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .checkout_create_request import CheckoutCreateRequest

__all__ = ["CheckoutCreateRequest"]
3 changes: 3 additions & 0 deletions src/ucp_sdk/models/schemas/shopping/checkout_update_req.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .checkout_update_request import CheckoutUpdateRequest

__all__ = ["CheckoutUpdateRequest"]
3 changes: 3 additions & 0 deletions src/ucp_sdk/models/schemas/shopping/discount_resp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .discount import Allocation, AppliedDiscount, Checkout, DiscountsObject

__all__ = ["Allocation", "AppliedDiscount", "Checkout", "DiscountsObject"]
Loading