From b8d663b47d2e402eed0f937c40030746991c03f2 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Thu, 26 Mar 2026 17:16:31 +0100 Subject: [PATCH 1/2] Skip mandatory validation with object_template 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 ++++++++----- tests/unit/sdk/spec/test_object.py | 16 ++++++++++++++++ 3 files changed, 25 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 000000000..42b03784d --- /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 30d9f93d3..4eb2b8821 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/tests/unit/sdk/spec/test_object.py b/tests/unit/sdk/spec/test_object.py index edb06891d..fda035c4c 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 == [] From fd0f0c0cabfadd6689f95ba1c90114c2bd0a7c44 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Thu, 26 Mar 2026 18:45:23 +0100 Subject: [PATCH 2/2] Add integration tests --- infrahub_sdk/testing/schemas/animal.py | 1 + tests/integration/test_spec_object.py | 60 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/infrahub_sdk/testing/schemas/animal.py b/infrahub_sdk/testing/schemas/animal.py index 559e75d48..bbc9992b2 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 c7320b388..c8e7eca32 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"