From 5224d24cd74758f120f791c70ace5820be77fbc1 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Thu, 26 Mar 2026 21:04:21 +0100 Subject: [PATCH] Skip mandatory validation with object_template (#895) When an object specifies an `object_template`, the template provides default values for mandatory fields (including resource pool allocations). The client-side validation was rejecting these objects because mandatory fields like were not present in the YAML data, even though the server would resolve them from the template. Skip the mandatory field check in `validate_object()` when `object_template` is present in the data, allowing the server to validate template completeness instead. --- changelog/+ifc2404.fixed.md | 1 + infrahub_sdk/spec/object.py | 13 +++--- infrahub_sdk/testing/schemas/animal.py | 1 + tests/integration/test_spec_object.py | 60 ++++++++++++++++++++++++++ tests/unit/sdk/spec/test_object.py | 16 +++++++ 5 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 changelog/+ifc2404.fixed.md diff --git a/changelog/+ifc2404.fixed.md b/changelog/+ifc2404.fixed.md new file mode 100644 index 00000000..42b03784 --- /dev/null +++ b/changelog/+ifc2404.fixed.md @@ -0,0 +1 @@ +Skip mandatory field validation during object loading when `object_template` is specified. diff --git a/infrahub_sdk/spec/object.py b/infrahub_sdk/spec/object.py index 30d9f93d..4eb2b882 100644 --- a/infrahub_sdk/spec/object.py +++ b/infrahub_sdk/spec/object.py @@ -264,11 +264,14 @@ async def validate_object( context = context.copy() if context else {} # First validate if all mandatory fields are present - errors.extend( - ObjectValidationError(position=[*position, element], message=f"{element} is mandatory") - for element in schema.mandatory_input_names - if not any([element in data, element in context]) - ) + # Skip mandatory check when an object_template is specified, as the template provides defaults, we expect the API server to just send a + # response with a failure if the template is not valid or doesn't provide all mandatory fields + if "object_template" not in data: + errors.extend( + ObjectValidationError(position=[*position, element], message=f"{element} is mandatory") + for element in schema.mandatory_input_names + if not any([element in data, element in context]) + ) # Validate if all attributes are valid for key, value in data.items(): diff --git a/infrahub_sdk/testing/schemas/animal.py b/infrahub_sdk/testing/schemas/animal.py index 559e75d4..bbc9992b 100644 --- a/infrahub_sdk/testing/schemas/animal.py +++ b/infrahub_sdk/testing/schemas/animal.py @@ -66,6 +66,7 @@ def schema_dog(self) -> NodeSchema: name="Dog", namespace=NAMESPACE, include_in_menu=True, + generate_template=True, inherit_from=[TESTING_ANIMAL], display_labels=["name__value", "breed__value"], attributes=[ diff --git a/tests/integration/test_spec_object.py b/tests/integration/test_spec_object.py index c7320b38..c8e7eca3 100644 --- a/tests/integration/test_spec_object.py +++ b/tests/integration/test_spec_object.py @@ -1,8 +1,10 @@ from pathlib import Path +from typing import Any import pytest from infrahub_sdk import InfrahubClient +from infrahub_sdk.exceptions import GraphQLError from infrahub_sdk.schema import SchemaRoot from infrahub_sdk.spec.menu import MenuFile from infrahub_sdk.spec.object import ObjectFile @@ -148,3 +150,61 @@ async def test_load_menu(self, client: InfrahubClient, branch_name: str, initial await menu_by_name["Animals"].children.fetch() peer_labels = [peer.display_label for peer in menu_by_name["Animals"].children.peers] assert sorted(peer_labels) == sorted(["Dog", "Cat"]) + + +def make_object_file(kind: str, data: list[dict[str, Any]]) -> ObjectFile: + return ObjectFile( + location=Path("inline"), + content={"apiVersion": "infrahub.app/v1", "kind": "Object", "spec": {"kind": kind, "data": data}}, + ) + + +class TestSpecObjectWithTemplate(TestInfrahubDockerClient, SchemaAnimal): + @pytest.fixture(scope="class") + async def initial_schema(self, default_branch: str, client: InfrahubClient, schema_base: SchemaRoot) -> None: + await client.schema.wait_until_converged(branch=default_branch) + resp = await client.schema.load( + schemas=[schema_base.to_schema_dict()], branch=default_branch, wait_until_converged=True + ) + assert resp.errors == {} + + async def test_create_owner(self, client: InfrahubClient, initial_schema: None) -> None: + owner = await client.create(kind="TestingPerson", name="Template Owner") + await owner.save() + + async def test_create_templates(self, client: InfrahubClient, initial_schema: None) -> None: + complete = make_object_file( + kind="TemplateTestingDog", data=[{"template_name": "Complete Dog", "breed": "Labrador"}] + ) + await complete.validate_format(client=client) + await complete.process(client=client) + + incomplete = make_object_file(kind="TemplateTestingDog", data=[{"template_name": "Incomplete Dog"}]) + await incomplete.validate_format(client=client) + await incomplete.process(client=client) + + async def test_dog_with_complete_template(self, client: InfrahubClient, initial_schema: None) -> None: + """Mandatory field is provided by the template.""" + obj = make_object_file( + kind="TestingDog", + data=[{"name": "Rex", "owner": "Template Owner", "object_template": "Complete Dog"}], + ) + await obj.validate_format(client=client) + await obj.process(client=client) + + dog = await client.get(kind="TestingDog", name__value="Rex") + assert dog.breed.value == "Labrador" + + async def test_dog_with_incomplete_template_fails(self, client: InfrahubClient, initial_schema: None) -> None: + """Mandatory field is not provided by the template.""" + obj = make_object_file( + kind="TestingDog", + data=[{"name": "Buddy", "owner": "Template Owner", "object_template": "Incomplete Dog"}], + ) + await obj.validate_format(client=client) + + with pytest.raises(GraphQLError) as exc_info: + await obj.process(client=client) + + assert len(exc_info.value.errors) == 1 + assert exc_info.value.errors[0]["message"] == "breed is mandatory for TestingDog at breed" diff --git a/tests/unit/sdk/spec/test_object.py b/tests/unit/sdk/spec/test_object.py index edb06891..fda035c4 100644 --- a/tests/unit/sdk/spec/test_object.py +++ b/tests/unit/sdk/spec/test_object.py @@ -9,6 +9,7 @@ from infrahub_sdk.exceptions import ValidationError from infrahub_sdk.node.related_node import RelatedNode from infrahub_sdk.spec.object import ( + InfrahubObjectFileData, ObjectFile, RelationshipDataFormat, get_relationship_info, @@ -456,3 +457,18 @@ def test_related_node_graphql_payload(test_case: RelatedNodePayloadTestCase) -> # Verify the payload structure assert payload == test_case.expected_payload, f"Expected payload {test_case.expected_payload}, got {payload}" + + +async def test_validate_object_skips_mandatory_check_with_object_template( + client_with_schema_01: InfrahubClient, +) -> None: + """When object_template is present, mandatory field validation should be skipped.""" + schema = await client_with_schema_01.schema.get(kind="BuiltinLocation") + + data = {"name": "Site1", "object_template": "Standard Site"} + errors = await InfrahubObjectFileData.validate_object( + client=client_with_schema_01, position=[1], schema=schema, data=data + ) + + mandatory_errors = [e for e in errors if e.message == "type is mandatory"] + assert mandatory_errors == []