diff --git a/CHANGELOG.md b/CHANGELOG.md index 785ce8c5..e17d1b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `datacontract export protobuf` supports a customizable package name via the `protoPackageName` custom property (#1381) + ### Fixed - SyntaxWarning during installation: `datacontract/lint/resolve.py:72: SyntaxWarning: 'return' in a 'finally' block return except_message` is handled properly diff --git a/datacontract/export/protobuf_exporter.py b/datacontract/export/protobuf_exporter.py index 2265f53b..cc8a4ed0 100644 --- a/datacontract/export/protobuf_exporter.py +++ b/datacontract/export/protobuf_exporter.py @@ -1,11 +1,16 @@ +import re from typing import List, Optional from open_data_contract_standard.model import OpenDataContractStandard, SchemaProperty from datacontract.export.exporter import Exporter +from datacontract.model.exceptions import DataContractException OBJECT_TYPES: set = {"object", "record", "struct"} +# A valid proto package is a dot-separated sequence of identifiers. +_PROTO_PACKAGE_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$") + class ProtoBufExporter(Exporter): def export(self, data_contract, schema_name, server, sql_server_type, export_args) -> str: @@ -31,6 +36,27 @@ def _get_logical_type_option(prop: SchemaProperty, key: str): return prop.logicalTypeOptions.get(key) +def _get_proto_package_name(data_contract: OpenDataContractStandard) -> str: + """ + Returns the Protobuf package name from the contract's customProperties + ("protoPackageName"), falling back to "example". + """ + if data_contract.customProperties is None: + return "example" + for cp in data_contract.customProperties: + if cp.property == "protoPackageName" and cp.value: + if not _PROTO_PACKAGE_PATTERN.match(cp.value): + raise DataContractException( + type="protobuf-export", + name="invalid-proto-package-name", + reason=f"Invalid protoPackageName '{cp.value}'. " + "Must be a dot-separated sequence of identifiers, e.g. 'com.example.mydata'.", + engine="datacontract", + ) + return cp.value + return "example" + + def to_protobuf(data_contract: OpenDataContractStandard) -> str: """ Generates a Protobuf file from the data contract specification. @@ -58,7 +84,7 @@ def to_protobuf(data_contract: OpenDataContractStandard) -> str: # Build header with syntax and package declarations. header = 'syntax = "proto3";\n\n' - package = "example" # Default package, can be customized + package: str = _get_proto_package_name(data_contract) header += f"package {package};\n\n" # Append enum definitions before messages. diff --git a/docs/docs/exports/protobuf.md b/docs/docs/exports/protobuf.md index 4da33e0b..cfd47dc7 100644 --- a/docs/docs/exports/protobuf.md +++ b/docs/docs/exports/protobuf.md @@ -51,3 +51,25 @@ message LineItems { ``` {/* END AUTOGENERATED EXAMPLE */} + +## Configuration + +You can customize the generated Protobuf package name using the data contract's `customProperties`: + +```yaml +kind: DataContract +apiVersion: v3.1.0 +id: my-contract +version: 1.0.0 +customProperties: + - property: protoPackageName + value: com.example.mydata +schema: + - name: Orders + properties: + # ... fields ... +``` + +### Supported Properties + +- **`protoPackageName`** (optional) — The package name for the generated Protobuf file. Defaults to `"example"` if not specified. diff --git a/tests/test_export_protobuf.py b/tests/test_export_protobuf.py index 407325f3..fd3c4a02 100644 --- a/tests/test_export_protobuf.py +++ b/tests/test_export_protobuf.py @@ -1,9 +1,11 @@ +import pytest import yaml from open_data_contract_standard.model import OpenDataContractStandard from typer.testing import CliRunner from datacontract.cli import app from datacontract.export.protobuf_exporter import to_protobuf +from datacontract.model.exceptions import DataContractException # logging.basicConfig(level=logging.DEBUG, force=True) @@ -130,3 +132,44 @@ def test_to_protobuf(): result = to_protobuf(data_contract).strip() assert result == expected_protobuf + + +def test_to_protobuf_custom_package_name(): + odcs_yaml = """ +kind: DataContract +apiVersion: v3.1.0 +id: test_protobuf +customProperties: + - property: protoPackageName + value: com.example.product +schema: + - name: Product + properties: + - name: id + logicalType: string +""" + data_contract = OpenDataContractStandard(**yaml.safe_load(odcs_yaml)) + + result = to_protobuf(data_contract) + + assert "package com.example.product;\n" in result + + +def test_to_protobuf_invalid_package_name(): + odcs_yaml = """ +kind: DataContract +apiVersion: v3.1.0 +id: test_protobuf +customProperties: + - property: protoPackageName + value: "123 not a package!" +schema: + - name: Product + properties: + - name: id + logicalType: string +""" + data_contract = OpenDataContractStandard(**yaml.safe_load(odcs_yaml)) + + with pytest.raises(DataContractException): + to_protobuf(data_contract)