diff --git a/src/core/schemas.py b/src/core/schemas.py index aa81055a8..458d05fa0 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -76,6 +76,23 @@ def model_dump(self, **kwargs): kwargs["exclude_none"] = True return super().model_dump(**kwargs) + def model_dump_json(self, **kwargs): + """Dump model to JSON string with AdCP-compliant defaults. + + By default, excludes None values to match AdCP spec where optional fields + should be omitted rather than set to null. This prevents JSON validation + errors from AdCP consumers that use "additionalProperties": false and don't + allow null for optional fields. + + Examples: + response = ListAuthorizedPropertiesResponse(publisher_domains=["example.com"]) + # Only includes publisher_domains, omits all None-valued optional fields + json_str = response.model_dump_json() # exclude_none=True by default + """ + if "exclude_none" not in kwargs: + kwargs["exclude_none"] = True + return super().model_dump_json(**kwargs) + class TaskStatus(str, Enum): """Standardized task status enum per AdCP MCP Status specification. diff --git a/tests/unit/test_adcp_json_serialization.py b/tests/unit/test_adcp_json_serialization.py new file mode 100644 index 000000000..b9b7169d5 --- /dev/null +++ b/tests/unit/test_adcp_json_serialization.py @@ -0,0 +1,88 @@ +"""Test that AdCP responses correctly exclude None values in JSON serialization. + +This is critical for client compatibility - the AdCP client (adcp/client npm package) +validates responses against JSON schemas that don't allow null for optional fields. + +Issue: PR #xxx - list_authorized_properties returned null for optional fields +Fix: AdCPBaseModel.model_dump_json() now defaults to exclude_none=True +""" + +import json + +from src.core.schema_adapters import ( + GetProductsResponse, + ListAuthorizedPropertiesResponse, + ListCreativeFormatsResponse, +) + + +def test_list_authorized_properties_excludes_none_in_json(): + """Test that model_dump_json() excludes None values by default. + + This prevents schema validation errors in the AdCP client which expects + optional fields to be omitted (not set to null). + """ + # Create response with only required field (all optional fields will be None) + response = ListAuthorizedPropertiesResponse(publisher_domains=["example.com"]) + + # Serialize to JSON + json_str = response.model_dump_json() + parsed = json.loads(json_str) + + # Verify None fields are not present in JSON + assert "publisher_domains" in parsed + assert "primary_channels" not in parsed # Should be excluded (None) + assert "primary_countries" not in parsed # Should be excluded (None) + assert "portfolio_description" not in parsed # Should be excluded (None) + assert "advertising_policies" not in parsed # Should be excluded (None) + assert "last_updated" not in parsed # Should be excluded (None) + assert "errors" not in parsed # Should be excluded (None) + + +def test_adcp_response_includes_explicit_values(): + """Test that explicitly set values are included in JSON.""" + response = ListAuthorizedPropertiesResponse( + publisher_domains=["example.com"], + primary_channels=["display", "video"], + advertising_policies="No tobacco or alcohol", + ) + + json_str = response.model_dump_json() + parsed = json.loads(json_str) + + # Verify explicitly set fields are included + assert parsed["publisher_domains"] == ["example.com"] + assert parsed["primary_channels"] == ["display", "video"] + assert parsed["advertising_policies"] == "No tobacco or alcohol" + + # Verify unset fields are still excluded + assert "primary_countries" not in parsed + assert "portfolio_description" not in parsed + + +def test_model_dump_also_excludes_none(): + """Test that model_dump() (dict) also excludes None by default.""" + response = ListAuthorizedPropertiesResponse(publisher_domains=["example.com"]) + + dump = response.model_dump() + + # Verify None fields are not present + assert "publisher_domains" in dump + assert "primary_channels" not in dump + assert "primary_countries" not in dump + assert "portfolio_description" not in dump + + +def test_other_responses_also_exclude_none(): + """Verify all AdCP response types exclude None values.""" + # GetProductsResponse + products_resp = GetProductsResponse(products=[]) + products_json = json.loads(products_resp.model_dump_json()) + assert "products" in products_json + # Should not have None-valued optional fields + + # ListCreativeFormatsResponse + formats_resp = ListCreativeFormatsResponse(formats=[]) + formats_json = json.loads(formats_resp.model_dump_json()) + assert "formats" in formats_json + # Should not have None-valued optional fields