Skip to content

Commit c27b827

Browse files
committed
fix: generate capability role schemas from dotted defs
1 parent 3e1aace commit c27b827

5 files changed

Lines changed: 238 additions & 15 deletions

File tree

preprocess_schemas.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,19 @@ def preprocess_full_schema(schema, entity_def=None):
317317
# --- Dotted $defs Flattening ---
318318

319319

320+
def _rewrite_defs_ref_path(rest, rename_map):
321+
"""Returns the rewritten $defs fragment path when a renamed target matches."""
322+
for old, new in sorted(
323+
rename_map.items(), key=lambda item: len(item[0]), reverse=True
324+
):
325+
if rest == old:
326+
return new
327+
prefix = old + "/"
328+
if rest.startswith(prefix):
329+
return new + rest[len(old) :]
330+
return None
331+
332+
320333
def _rewrite_local_defs_refs(node, rename_map):
321334
"""Walks a schema tree and rewrites local $defs refs whose target was renamed."""
322335
prefix = "#/$defs/"
@@ -327,9 +340,9 @@ def _rewrite_local_defs_refs(node, rename_map):
327340
if not isinstance(ref, str) or not ref.startswith(prefix):
328341
continue
329342
rest = ref[len(prefix) :]
330-
name, sep, tail = rest.partition("/")
331-
if name in rename_map:
332-
n["$ref"] = prefix + rename_map[name] + (sep + tail if sep else "")
343+
rewritten = _rewrite_defs_ref_path(rest, rename_map)
344+
if rewritten is not None:
345+
n["$ref"] = prefix + rewritten
333346

334347

335348
def _rewrite_external_defs_refs(schema_path, schema, global_rename_maps):
@@ -362,11 +375,9 @@ def _rewrite_external_defs_refs(schema_path, schema, global_rename_maps):
362375
rename_map = global_rename_maps[target_path_str]
363376

364377
rest = fragment_part[len(prefix) :]
365-
name, sep, tail = rest.partition("/")
366-
if name in rename_map:
367-
new_name = rename_map[name]
368-
new_fragment = prefix + new_name + (sep + tail if sep else "")
369-
n["$ref"] = file_part + "#" + new_fragment
378+
rewritten = _rewrite_defs_ref_path(rest, rename_map)
379+
if rewritten is not None:
380+
n["$ref"] = file_part + "#" + prefix + rewritten
370381

371382

372383
def flatten_dotted_defs(schema):
@@ -384,16 +395,44 @@ def flatten_dotted_defs(schema):
384395
class name like 'Checkout'); fall back to dot-replaced-with-underscore
385396
(e.g. 'DevUcpShoppingFulfillment') if the bare tail would collide with
386397
an existing def in the same file.
398+
399+
Capability role containers: a dotted def whose value holds exactly the
400+
'platform_schema' and 'business_schema' keys is not a schema itself but the
401+
mount point where a capability contributes its two role schemas. Renaming it
402+
whole would only produce a meaningless Any alias, so it is split into two
403+
generatable defs ('<tail>_platform_schema' / '<tail>_business_schema');
404+
refs into the container ('.../<role>') are remapped to the split defs.
387405
"""
388406
defs = schema.get("$defs")
389407
if not isinstance(defs, dict):
390408
return {}
391409

392410
existing = set(defs.keys())
393411
rename_map = {}
412+
split_map = {}
394413
for old in list(defs.keys()):
395414
if "." not in old:
396415
continue
416+
value = defs[old]
417+
if isinstance(value, dict) and set(value.keys()) == {
418+
"platform_schema",
419+
"business_schema",
420+
}:
421+
tail = old.rsplit(".", 1)[-1]
422+
platform_key = tail + "_platform_schema"
423+
business_key = tail + "_business_schema"
424+
if platform_key in existing or business_key in existing:
425+
# Both split candidates collide; leave as-is rather than
426+
# risk corruption.
427+
continue
428+
defs[platform_key] = value["platform_schema"]
429+
defs[business_key] = value["business_schema"]
430+
del defs[old]
431+
existing.discard(old)
432+
existing.update([platform_key, business_key])
433+
split_map[old + "/platform_schema"] = platform_key
434+
split_map[old + "/business_schema"] = business_key
435+
continue
397436
tail = old.rsplit(".", 1)[-1]
398437
if tail and tail not in existing:
399438
new = tail
@@ -406,11 +445,12 @@ class name like 'Checkout'); fall back to dot-replaced-with-underscore
406445
existing.discard(old)
407446
existing.add(new)
408447

409-
if not rename_map:
448+
if not rename_map and not split_map:
410449
return {}
411450

412451
for old, new in rename_map.items():
413452
defs[new] = defs.pop(old)
453+
rename_map.update(split_map)
414454
_rewrite_local_defs_refs(schema, rename_map)
415455
return rename_map
416456

src/ucp_sdk/models/schemas/common/identity_linking.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
from pydantic import BaseModel, ConfigDict, Field
2424
from typing_extensions import TypeAliasType
2525

26+
from ..capability import BusinessSchema, PlatformSchema
2627
from .types import description as description_1
28+
from .types import reverse_domain_name
2729

2830
IdentityLinking = TypeAliasType(
2931
"IdentityLinking", Annotated[Any, Field(..., title="Identity Linking")]
@@ -76,4 +78,38 @@ class Provider(BaseModel):
7678
"""
7779

7880

79-
IdentityLinking1 = TypeAliasType("IdentityLinking1", Any)
81+
class Config(BaseModel):
82+
model_config = ConfigDict(
83+
extra="allow",
84+
)
85+
providers: (
86+
dict[reverse_domain_name.ReverseDomainName, list[Provider]] | None
87+
) = None
88+
"""
89+
Map of trusted external identity providers keyed by reverse-domain identifier. Each key maps to an array of mechanism entries — an IdP namespace MAY offer multiple token acquisition mechanisms. Declares which upstream IdPs the business will accept JWT bearer assertions from for the Accelerated IdP Flow (chaining via RFC 8693 + RFC 7523). This field is additive: direct OAuth against the business domain via RFC 8414 discovery is always available regardless of 'providers' content. Businesses MUST NOT list their own authorization server here — chaining-to-self is degenerate, and direct OAuth covers that path. When absent, empty, or when no listed mechanism is supported by the platform, platforms run direct OAuth on the business domain.
90+
"""
91+
scopes: dict[ScopeToken, ScopePolicy]
92+
"""
93+
Map of user-authenticated scopes offered by this business. Each key is an OAuth scope string formed as '{capability}:{scope}' (e.g. 'dev.ucp.shopping.order:read'). Scope presence in this map declares that the corresponding operations require a user identity token. Operations not gated by any listed scope operate at whatever access level the business permits; UCP does not prescribe a default. Each value is a per-scope policy object (empty object means user auth required with no additional policy).
94+
"""
95+
96+
97+
class IdentityLinkingPlatformSchema(PlatformSchema):
98+
"""
99+
Platform-level identity linking capability declaration. Platforms advertise support for identity linking; no auth-specific config is required.
100+
"""
101+
102+
model_config = ConfigDict(
103+
extra="allow",
104+
)
105+
106+
107+
class IdentityLinkingBusinessSchema(BusinessSchema):
108+
"""
109+
Business-level identity linking configuration. Businesses declare the user-authenticated scopes they offer in 'config.scopes'.
110+
"""
111+
112+
model_config = ConfigDict(
113+
extra="allow",
114+
)
115+
config: Config

src/ucp_sdk/models/schemas/common/payment_authentication.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class DevUcpCommonPaymentDeviceDataCollectionItem(BaseModel):
5555
config: Config
5656

5757

58-
class Config1(BaseModel):
58+
class Config2(BaseModel):
5959
model_config = ConfigDict(
6060
extra="allow",
6161
)
@@ -73,7 +73,7 @@ class DevUcpCommonPaymentThreeDsChallengeItem(BaseModel):
7373
model_config = ConfigDict(
7474
extra="allow",
7575
)
76-
config: Config1
76+
config: Config2
7777

7878

7979
class Actions(BaseModel):

src/ucp_sdk/models/schemas/shopping/fulfillment.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pydantic import BaseModel, ConfigDict, Field
2424
from typing_extensions import TypeAliasType
2525

26+
from ..capability import BusinessSchema, PlatformSchema
2627
from ..common.types import description as description_1
2728
from .catalog_lookup import DetailProduct
2829
from .catalog_lookup import GetProductRequest as GetProductRequest_1
@@ -34,6 +35,7 @@
3435
from .catalog_search import SearchResponse as SearchResponse_1
3536
from .checkout import Checkout as Checkout_1
3637
from .types import availability as availability_1
38+
from .types import business_fulfillment_config
3739
from .types import fulfillment as fulfillment_1
3840
from .types import (
3941
fulfillment_available_method,
@@ -42,6 +44,7 @@
4244
fulfillment_method,
4345
fulfillment_option,
4446
fulfillment_option_base,
47+
platform_fulfillment_config,
4548
)
4649
from .types.product import Product
4750
from .types.search_filters import SearchFilters
@@ -56,9 +59,6 @@
5659
"""
5760

5861

59-
DevUcpShoppingFulfillment = TypeAliasType("DevUcpShoppingFulfillment", Any)
60-
61-
6262
FulfillmentAvailableMethod = TypeAliasType(
6363
"FulfillmentAvailableMethod",
6464
fulfillment_available_method.FulfillmentAvailableMethod,
@@ -109,6 +109,34 @@ class CatalogFulfillment(BaseModel):
109109
"""
110110

111111

112+
class FulfillmentPlatformSchema(PlatformSchema):
113+
"""
114+
Platform-level fulfillment capability configuration
115+
"""
116+
117+
model_config = ConfigDict(
118+
extra="allow",
119+
)
120+
config: platform_fulfillment_config.PlatformFulfillmentConfig | None = None
121+
"""
122+
Platform fulfillment configuration
123+
"""
124+
125+
126+
class FulfillmentBusinessSchema(BusinessSchema):
127+
"""
128+
Business-level fulfillment capability configuration
129+
"""
130+
131+
model_config = ConfigDict(
132+
extra="allow",
133+
)
134+
config: business_fulfillment_config.BusinessFulfillmentConfig | None = None
135+
"""
136+
Business fulfillment configuration
137+
"""
138+
139+
112140
FulfillmentOption = TypeAliasType(
113141
"FulfillmentOption", fulfillment_option.FulfillmentOption
114142
)

tests/test_codegen_pipeline.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,58 @@ def test_flatten_dotted_defs_rewrites_local_refs(self) -> None:
298298
"#/$defs/dev_ucp_shopping_checkout",
299299
)
300300

301+
def test_flatten_dotted_defs_splits_capability_role_containers(
302+
self,
303+
) -> None:
304+
"""Role containers split into two defs and refs into them follow."""
305+
role_platform = {"title": "Platform", "allOf": [{"type": "object"}]}
306+
role_business = {"title": "Business", "allOf": [{"type": "object"}]}
307+
schema = {
308+
"$defs": {
309+
"dev.ucp.common.identity_linking": {
310+
"platform_schema": role_platform,
311+
"business_schema": role_business,
312+
},
313+
},
314+
"properties": {
315+
"platform": {
316+
"$ref": "#/$defs/dev.ucp.common.identity_linking/platform_schema"
317+
},
318+
"business": {
319+
"$ref": "#/$defs/dev.ucp.common.identity_linking/business_schema"
320+
},
321+
},
322+
}
323+
324+
rename_map = preprocess_schemas.flatten_dotted_defs(schema)
325+
326+
self.assertEqual(
327+
rename_map,
328+
{
329+
"dev.ucp.common.identity_linking/platform_schema": (
330+
"identity_linking_platform_schema"
331+
),
332+
"dev.ucp.common.identity_linking/business_schema": (
333+
"identity_linking_business_schema"
334+
),
335+
},
336+
)
337+
self.assertEqual(
338+
schema["$defs"],
339+
{
340+
"identity_linking_platform_schema": role_platform,
341+
"identity_linking_business_schema": role_business,
342+
},
343+
)
344+
self.assertEqual(
345+
schema["properties"]["platform"]["$ref"],
346+
"#/$defs/identity_linking_platform_schema",
347+
)
348+
self.assertEqual(
349+
schema["properties"]["business"]["$ref"],
350+
"#/$defs/identity_linking_business_schema",
351+
)
352+
301353
def test_rewrite_external_defs_refs_uses_target_rename_map(self) -> None:
302354
"""External references follow renames made in the target schema."""
303355
with tempfile.TemporaryDirectory() as temp_dir:
@@ -1084,6 +1136,73 @@ def test_request_variants_enforce_property_names(self):
10841136
)
10851137

10861138

1139+
@unittest.skipUnless(
1140+
HAVE_SDK, "requires the installed package (pip install -e .)"
1141+
)
1142+
class IdentityLinkingRoleSchemaTest(unittest.TestCase):
1143+
"""identity_linking.json keeps its role schemas instead of Any aliases.
1144+
1145+
The dotted 'dev.ucp.common.identity_linking' def is a capability role
1146+
container. Flattening must split it into two generatable defs so the
1147+
business role keeps the upstream contract: 'config.scopes' required with
1148+
OAuth scope-token keys.
1149+
"""
1150+
1151+
def _business(self):
1152+
from ucp_sdk.models.schemas.common.identity_linking import (
1153+
IdentityLinkingBusinessSchema,
1154+
)
1155+
1156+
return IdentityLinkingBusinessSchema
1157+
1158+
def _base(self):
1159+
return {
1160+
"version": "2026-08-25",
1161+
"schema": "https://ucp.dev/2026-08-25/schemas/common/identity_linking",
1162+
}
1163+
1164+
def test_platform_role_schema_exists(self):
1165+
from ucp_sdk.models.schemas.common.identity_linking import (
1166+
IdentityLinkingPlatformSchema,
1167+
)
1168+
1169+
IdentityLinkingPlatformSchema(
1170+
version="2026-08-25",
1171+
**{
1172+
"schema": "https://ucp.dev/2026-08-25/schemas/common/identity_linking"
1173+
},
1174+
spec="https://ucp.dev/specification/common/identity-linking",
1175+
)
1176+
1177+
def test_business_config_with_scopes_accepted(self):
1178+
obj = self._business().model_validate(
1179+
{
1180+
**self._base(),
1181+
"config": {"scopes": {"dev.ucp.shopping.order:read": {}}},
1182+
}
1183+
)
1184+
self.assertEqual(
1185+
list(obj.config.scopes), ["dev.ucp.shopping.order:read"]
1186+
)
1187+
self.assertIsNone(
1188+
obj.config.scopes["dev.ucp.shopping.order:read"].description
1189+
)
1190+
1191+
def test_missing_config_rejected(self):
1192+
with self.assertRaisesRegex(ValidationError, "config"):
1193+
self._business().model_validate(self._base())
1194+
1195+
def test_config_without_scopes_rejected(self):
1196+
with self.assertRaisesRegex(ValidationError, "scopes"):
1197+
self._business().model_validate({**self._base(), "config": {}})
1198+
1199+
def test_malformed_scope_key_rejected(self):
1200+
with self.assertRaisesRegex(ValidationError, "pattern"):
1201+
self._business().model_validate(
1202+
{**self._base(), "config": {"scopes": {"BAD": {}}}}
1203+
)
1204+
1205+
10871206
class PropertyNamesInjectorTest(unittest.TestCase):
10881207
"""The propertyNames post-generation injector's own behavior."""
10891208

0 commit comments

Comments
 (0)