Skip to content

Commit dfa3d57

Browse files
Return a typed reference's value as its model types it
A resource named with its model gives c.ref the field's type, and the signature promises it: c.ref(cluster.status.secrets) is typed list[Secret]. It returned the JSON observed state stores instead, a list of dicts, so code written against the type failed at runtime, and code written against the JSON failed type checking. Porting Modelplane hit both. c.ref now validates the value against the field's type with a pydantic TypeAdapter, so a nested object comes back as its model and a list of them as a list of models. A value that doesn't fit is an error naming the field, rather than a model built from the wrong data. Without a model there's no type to fit, and the value is the JSON as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 58353f2 commit dfa3d57

2 files changed

Lines changed: 60 additions & 2 deletions

File tree

‎crossplane/function/reference.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -609,13 +609,26 @@ def ref(self, value: V) -> V:
609609
state, and the dependency means Crossplane doesn't create this
610610
resource until the value exists.
611611
612+
A resource named with its model returns the value as the model
613+
types it: a nested object comes back as its model, and a list of
614+
them as a list of models, rather than as the JSON observed state
615+
stores. Without a model the value is the JSON.
616+
612617
Raises:
613618
TypeError: value isn't a field of a named resource.
614-
ValueError: value is the whole resource rather than a field of it.
619+
ValueError: value is the whole resource rather than a field of
620+
it, or the observed value doesn't fit the model's type for it.
615621
"""
616622
marker = _decode(ref(value))
617623
assert marker is not None # noqa: S101 # ref always returns a marker.
618-
return typing.cast(V, self._get(*marker, value._describe()))
624+
v = self._get(*marker, value._describe())
625+
if v is None or value._model is None:
626+
return typing.cast(V, v)
627+
try:
628+
return typing.cast(V, pydantic.TypeAdapter(value._model).validate_python(v))
629+
except pydantic.ValidationError as e:
630+
msg = f"{value._describe()} doesn't match its model: {e}"
631+
raise ValueError(msg) from e
619632

620633
def external_name(self, named_resource: typing.Any) -> str | None:
621634
"""Read a named resource's external name, and depend on that resource.

‎tests/test_reference.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ def observed(**resources: dict) -> fnv1.State:
8888
"status": {"atProvider": {"id": "vpc-0123", "arn": "arn:aws:ec2:vpc/vpc-0123"}},
8989
}
9090

91+
POOL_OBSERVED = {"ranges": [{"from": "10.0.0.0"}, {"from": "10.1.0.0"}]}
92+
9193
ORDERED = fnv1.RequestMeta(
9294
capabilities=[fnv1.CAPABILITY_CAPABILITIES, fnv1.CAPABILITY_DEPENDENCIES]
9395
)
@@ -514,6 +516,49 @@ def test_returns_values_and_records_edges(self) -> None:
514516
)
515517
self.assertEqual(rsp.results, [])
516518

519+
def test_typed_values_come_back_as_models(self) -> None:
520+
req = fnv1.RunFunctionRequest(
521+
meta=ORDERED, observed=observed(pool=POOL_OBSERVED)
522+
)
523+
rsp = response.to(req)
524+
pool = reference.named("pool", Pool)
525+
526+
with reference.composing(req, rsp, "subnet") as c:
527+
ranges = c.ref(pool.ranges)
528+
first = c.ref(pool.ranges[0])
529+
start = c.ref(pool.ranges[0].from_)
530+
531+
self.assertEqual(
532+
ranges, [Range(**{"from": "10.0.0.0"}), Range(**{"from": "10.1.0.0"})]
533+
)
534+
self.assertIsInstance(first, Range)
535+
self.assertEqual(start, "10.0.0.0")
536+
537+
def test_untyped_values_come_back_as_json(self) -> None:
538+
req = fnv1.RunFunctionRequest(
539+
meta=ORDERED, observed=observed(pool=POOL_OBSERVED)
540+
)
541+
rsp = response.to(req)
542+
pool = reference.named("pool")
543+
544+
with reference.composing(req, rsp, "subnet") as c:
545+
ranges = c.ref(pool.ranges)
546+
547+
self.assertEqual(ranges, [{"from": "10.0.0.0"}, {"from": "10.1.0.0"}])
548+
549+
def test_values_that_dont_fit_the_model_fail(self) -> None:
550+
req = fnv1.RunFunctionRequest(
551+
meta=ORDERED, observed=observed(pool={"ranges": [{"from": 7}]})
552+
)
553+
rsp = response.to(req)
554+
pool = reference.named("pool", Pool)
555+
556+
with (
557+
self.assertRaisesRegex(ValueError, "pool.ranges doesn't match its model"),
558+
reference.composing(req, rsp, "subnet") as c,
559+
):
560+
c.ref(pool.ranges)
561+
517562
def test_works_in_fields_that_are_not_strings(self) -> None:
518563
vpc_body = {"spec": {"forProvider": {"enableDnsSupport": True}}}
519564
req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=vpc_body))

0 commit comments

Comments
 (0)