From 231740c84a7b9200854f82f001b574a0bd03694b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Br=C3=BCderl?= Date: Wed, 8 Jul 2026 14:23:51 +0200 Subject: [PATCH] fix(sr-normalize): reference inlined deps by their local name The plugin inlines every non-WKT transitive dependency as a top-level type in the target's package, then references it by name. The naming logic stripped only the package prefix the referred type shared with the referring message. That works when everything lives in one package, and breaks the moment a dependency comes from another package: the emitted reference keeps the non-shared package segments while the type was inlined under its bare name, so the reference dangles and Schema Registry rejects the schema. Reference each type by its path within its own file instead: strip the type's own package, not the prefix it happens to share with the referrer. Top-level types resolve to their bare name, nested types to Parent.Nested. Well-known google/protobuf types stay imports and keep their full name. Same-package output is byte-identical, so existing schemas are unchanged. Add a cross-package example (example.other.v1.ProviderType referenced by example.v1.Event) with an SR round-trip test that registers the schema against a real Schema Registry, plus a unit test for the naming logic. The order/product .pb.go changes are a regeneration: their checked-in go_package string had been renamed textually from protoc-gen-go-sr to protoc-gen-go-sr-normalize without regenerating, leaving a stale descriptor length prefix that panics at package init. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gen/go/example/other/v1/provider.pb.go | 150 ++++++++++++++++ .../example/gen/go/example/v1/event.pb.go | 162 ++++++++++++++++++ .../example/gen/go/example/v1/event.sr.pb.go | 39 +++++ .../example/gen/go/example/v1/order.pb.go | 4 +- .../example/gen/go/example/v1/product.pb.go | 4 +- .../proto/example/other/v1/provider.proto | 16 ++ .../example/proto/example/v1/event.proto | 22 +++ protoc-gen-go-sr-normalize/main.go | 52 ++++-- protoc-gen-go-sr-normalize/sr_test.go | 77 +++++++++ 9 files changed, 503 insertions(+), 23 deletions(-) create mode 100644 protoc-gen-go-sr-normalize/example/gen/go/example/other/v1/provider.pb.go create mode 100644 protoc-gen-go-sr-normalize/example/gen/go/example/v1/event.pb.go create mode 100644 protoc-gen-go-sr-normalize/example/gen/go/example/v1/event.sr.pb.go create mode 100644 protoc-gen-go-sr-normalize/example/proto/example/other/v1/provider.proto create mode 100644 protoc-gen-go-sr-normalize/example/proto/example/v1/event.proto diff --git a/protoc-gen-go-sr-normalize/example/gen/go/example/other/v1/provider.pb.go b/protoc-gen-go-sr-normalize/example/gen/go/example/other/v1/provider.pb.go new file mode 100644 index 0000000..ba3a9c6 --- /dev/null +++ b/protoc-gen-go-sr-normalize/example/gen/go/example/other/v1/provider.pb.go @@ -0,0 +1,150 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: example/other/v1/provider.proto + +package otherv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ProviderType deliberately lives in a DIFFERENT proto package +// (example.other.v1) than the messages that reference it. It exercises +// cross-package dependency inlining: the generator must inline this enum as a +// top-level type in the target's package and reference it by its bare local +// name, not a partially package-qualified name. +type ProviderType int32 + +const ( + ProviderType_PROVIDER_TYPE_UNSPECIFIED ProviderType = 0 + ProviderType_PROVIDER_TYPE_OPENAI ProviderType = 1 + ProviderType_PROVIDER_TYPE_ANTHROPIC ProviderType = 2 +) + +// Enum value maps for ProviderType. +var ( + ProviderType_name = map[int32]string{ + 0: "PROVIDER_TYPE_UNSPECIFIED", + 1: "PROVIDER_TYPE_OPENAI", + 2: "PROVIDER_TYPE_ANTHROPIC", + } + ProviderType_value = map[string]int32{ + "PROVIDER_TYPE_UNSPECIFIED": 0, + "PROVIDER_TYPE_OPENAI": 1, + "PROVIDER_TYPE_ANTHROPIC": 2, + } +) + +func (x ProviderType) Enum() *ProviderType { + p := new(ProviderType) + *p = x + return p +} + +func (x ProviderType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProviderType) Descriptor() protoreflect.EnumDescriptor { + return file_example_other_v1_provider_proto_enumTypes[0].Descriptor() +} + +func (ProviderType) Type() protoreflect.EnumType { + return &file_example_other_v1_provider_proto_enumTypes[0] +} + +func (x ProviderType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProviderType.Descriptor instead. +func (ProviderType) EnumDescriptor() ([]byte, []int) { + return file_example_other_v1_provider_proto_rawDescGZIP(), []int{0} +} + +var File_example_other_v1_provider_proto protoreflect.FileDescriptor + +const file_example_other_v1_provider_proto_rawDesc = "" + + "\n" + + "\x1fexample/other/v1/provider.proto\x12\x10example.other.v1*d\n" + + "\fProviderType\x12\x1d\n" + + "\x19PROVIDER_TYPE_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14PROVIDER_TYPE_OPENAI\x10\x01\x12\x1b\n" + + "\x17PROVIDER_TYPE_ANTHROPIC\x10\x02B\xee\x01\n" + + "\x14com.example.other.v1B\rProviderProtoP\x01Zegithub.com/redpanda-data/common-go/protoc-gen-go-sr-normalize/example/gen/go/example/other/v1;otherv1\xa2\x02\x03EOX\xaa\x02\x10Example.Other.V1\xca\x02\x10Example\\Other\\V1\xe2\x02\x1cExample\\Other\\V1\\GPBMetadata\xea\x02\x12Example::Other::V1b\x06proto3" + +var ( + file_example_other_v1_provider_proto_rawDescOnce sync.Once + file_example_other_v1_provider_proto_rawDescData []byte +) + +func file_example_other_v1_provider_proto_rawDescGZIP() []byte { + file_example_other_v1_provider_proto_rawDescOnce.Do(func() { + file_example_other_v1_provider_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_example_other_v1_provider_proto_rawDesc), len(file_example_other_v1_provider_proto_rawDesc))) + }) + return file_example_other_v1_provider_proto_rawDescData +} + +var file_example_other_v1_provider_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_example_other_v1_provider_proto_goTypes = []any{ + (ProviderType)(0), // 0: example.other.v1.ProviderType +} +var file_example_other_v1_provider_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_example_other_v1_provider_proto_init() } +func file_example_other_v1_provider_proto_init() { + if File_example_other_v1_provider_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_example_other_v1_provider_proto_rawDesc), len(file_example_other_v1_provider_proto_rawDesc)), + NumEnums: 1, + NumMessages: 0, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_example_other_v1_provider_proto_goTypes, + DependencyIndexes: file_example_other_v1_provider_proto_depIdxs, + EnumInfos: file_example_other_v1_provider_proto_enumTypes, + }.Build() + File_example_other_v1_provider_proto = out.File + file_example_other_v1_provider_proto_goTypes = nil + file_example_other_v1_provider_proto_depIdxs = nil +} diff --git a/protoc-gen-go-sr-normalize/example/gen/go/example/v1/event.pb.go b/protoc-gen-go-sr-normalize/example/gen/go/example/v1/event.pb.go new file mode 100644 index 0000000..a546ae7 --- /dev/null +++ b/protoc-gen-go-sr-normalize/example/gen/go/example/v1/event.pb.go @@ -0,0 +1,162 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: example/v1/event.proto + +package examplev1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + _ "buf.build/gen/go/redpandadata/common/protocolbuffers/go/redpanda/api/common/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + + v1 "github.com/redpanda-data/common-go/protoc-gen-go-sr-normalize/example/gen/go/example/other/v1" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Event references ProviderType from a different proto package +// (example.other.v1). The generated schema must inline ProviderType as a +// top-level enum and reference it by its local name "ProviderType" -- not +// "other.v1.ProviderType", which would be a dangling reference that Schema +// Registry rejects. +type Event struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Provider v1.ProviderType `protobuf:"varint,2,opt,name=provider,proto3,enum=example.other.v1.ProviderType" json:"provider,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Event) Reset() { + *x = Event{} + mi := &file_example_v1_event_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Event) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Event) ProtoMessage() {} + +func (x *Event) ProtoReflect() protoreflect.Message { + mi := &file_example_v1_event_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Event.ProtoReflect.Descriptor instead. +func (*Event) Descriptor() ([]byte, []int) { + return file_example_v1_event_proto_rawDescGZIP(), []int{0} +} + +func (x *Event) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Event) GetProvider() v1.ProviderType { + if x != nil { + return x.Provider + } + return v1.ProviderType(0) +} + +var File_example_v1_event_proto protoreflect.FileDescriptor + +const file_example_v1_event_proto_rawDesc = "" + + "\n" + + "\x16example/v1/event.proto\x12\n" + + "example.v1\x1a\x1fexample/other/v1/provider.proto\x1a,redpanda/api/common/v1/schema_registry.proto\"o\n" + + "\x05Event\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12:\n" + + "\bprovider\x18\x02 \x01(\x0e2\x1e.example.other.v1.ProviderTypeR\bprovider:\x1a\x82\xc0\a\x16\n" + + "\x14example.events-valueB\xc8\x01\n" + + "\x0ecom.example.v1B\n" + + "EventProtoP\x01Zagithub.com/redpanda-data/common-go/protoc-gen-go-sr-normalize/example/gen/go/example/v1;examplev1\xa2\x02\x03EXX\xaa\x02\n" + + "Example.V1\xca\x02\n" + + "Example\\V1\xe2\x02\x16Example\\V1\\GPBMetadata\xea\x02\vExample::V1b\x06proto3" + +var ( + file_example_v1_event_proto_rawDescOnce sync.Once + file_example_v1_event_proto_rawDescData []byte +) + +func file_example_v1_event_proto_rawDescGZIP() []byte { + file_example_v1_event_proto_rawDescOnce.Do(func() { + file_example_v1_event_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_example_v1_event_proto_rawDesc), len(file_example_v1_event_proto_rawDesc))) + }) + return file_example_v1_event_proto_rawDescData +} + +var file_example_v1_event_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_example_v1_event_proto_goTypes = []any{ + (*Event)(nil), // 0: example.v1.Event + (v1.ProviderType)(0), // 1: example.other.v1.ProviderType +} +var file_example_v1_event_proto_depIdxs = []int32{ + 1, // 0: example.v1.Event.provider:type_name -> example.other.v1.ProviderType + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_example_v1_event_proto_init() } +func file_example_v1_event_proto_init() { + if File_example_v1_event_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_example_v1_event_proto_rawDesc), len(file_example_v1_event_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_example_v1_event_proto_goTypes, + DependencyIndexes: file_example_v1_event_proto_depIdxs, + MessageInfos: file_example_v1_event_proto_msgTypes, + }.Build() + File_example_v1_event_proto = out.File + file_example_v1_event_proto_goTypes = nil + file_example_v1_event_proto_depIdxs = nil +} diff --git a/protoc-gen-go-sr-normalize/example/gen/go/example/v1/event.sr.pb.go b/protoc-gen-go-sr-normalize/example/gen/go/example/v1/event.sr.pb.go new file mode 100644 index 0000000..d072003 --- /dev/null +++ b/protoc-gen-go-sr-normalize/example/gen/go/example/v1/event.sr.pb.go @@ -0,0 +1,39 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go-sr-normalize. DO NOT EDIT. + +package examplev1 + +// EventSRSchema is a self-contained proto for Schema Registry. +// The target message is at index 0. +const EventSRSchema = `// Generated by protoc-gen-go-sr-normalize. DO NOT EDIT. +syntax = "proto3"; +package example.v1; + +message Event { + string id = 1; + ProviderType provider = 2; +} + +enum ProviderType { + PROVIDER_TYPE_UNSPECIFIED = 0; + PROVIDER_TYPE_OPENAI = 1; + PROVIDER_TYPE_ANTHROPIC = 2; +} + +` + +// EventSRSubject is the Schema Registry subject name. +const EventSRSubject = "example.events-value" diff --git a/protoc-gen-go-sr-normalize/example/gen/go/example/v1/order.pb.go b/protoc-gen-go-sr-normalize/example/gen/go/example/v1/order.pb.go index 0641a5d..3b7dcc7 100644 --- a/protoc-gen-go-sr-normalize/example/gen/go/example/v1/order.pb.go +++ b/protoc-gen-go-sr-normalize/example/gen/go/example/v1/order.pb.go @@ -232,9 +232,9 @@ const file_example_v1_order_proto_rawDesc = "" + "\x18ORDER_STATUS_UNSPECIFIED\x10\x00\x12\x18\n" + "\x14ORDER_STATUS_PENDING\x10\x01\x12\x18\n" + "\x14ORDER_STATUS_SHIPPED\x10\x02\x12\x1a\n" + - "\x16ORDER_STATUS_DELIVERED\x10\x03B\xbe\x01\n" + + "\x16ORDER_STATUS_DELIVERED\x10\x03B\xc8\x01\n" + "\x0ecom.example.v1B\n" + - "OrderProtoP\x01ZWgithub.com/redpanda-data/common-go/protoc-gen-go-sr-normalize/example/gen/go/example/v1;examplev1\xa2\x02\x03EXX\xaa\x02\n" + + "OrderProtoP\x01Zagithub.com/redpanda-data/common-go/protoc-gen-go-sr-normalize/example/gen/go/example/v1;examplev1\xa2\x02\x03EXX\xaa\x02\n" + "Example.V1\xca\x02\n" + "Example\\V1\xe2\x02\x16Example\\V1\\GPBMetadata\xea\x02\vExample::V1b\x06proto3" diff --git a/protoc-gen-go-sr-normalize/example/gen/go/example/v1/product.pb.go b/protoc-gen-go-sr-normalize/example/gen/go/example/v1/product.pb.go index f9ced92..62c77ab 100644 --- a/protoc-gen-go-sr-normalize/example/gen/go/example/v1/product.pb.go +++ b/protoc-gen-go-sr-normalize/example/gen/go/example/v1/product.pb.go @@ -370,8 +370,8 @@ const file_example_v1_product_proto_rawDesc = "" + "\x0eProductService\x12T\n" + "\rCreateProduct\x12 .example.v1.CreateProductRequest\x1a!.example.v1.CreateProductResponse\x12K\n" + "\n" + - "GetProduct\x12\x1d.example.v1.GetProductRequest\x1a\x1e.example.v1.GetProductResponseB\xc0\x01\n" + - "\x0ecom.example.v1B\fProductProtoP\x01ZWgithub.com/redpanda-data/common-go/protoc-gen-go-sr-normalize/example/gen/go/example/v1;examplev1\xa2\x02\x03EXX\xaa\x02\n" + + "GetProduct\x12\x1d.example.v1.GetProductRequest\x1a\x1e.example.v1.GetProductResponseB\xca\x01\n" + + "\x0ecom.example.v1B\fProductProtoP\x01Zagithub.com/redpanda-data/common-go/protoc-gen-go-sr-normalize/example/gen/go/example/v1;examplev1\xa2\x02\x03EXX\xaa\x02\n" + "Example.V1\xca\x02\n" + "Example\\V1\xe2\x02\x16Example\\V1\\GPBMetadata\xea\x02\vExample::V1b\x06proto3" diff --git a/protoc-gen-go-sr-normalize/example/proto/example/other/v1/provider.proto b/protoc-gen-go-sr-normalize/example/proto/example/other/v1/provider.proto new file mode 100644 index 0000000..da630f1 --- /dev/null +++ b/protoc-gen-go-sr-normalize/example/proto/example/other/v1/provider.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package example.other.v1; + +option go_package = "github.com/redpanda-data/common-go/protoc-gen-go-sr/example/gen/go/example/other/v1;otherv1"; + +// ProviderType deliberately lives in a DIFFERENT proto package +// (example.other.v1) than the messages that reference it. It exercises +// cross-package dependency inlining: the generator must inline this enum as a +// top-level type in the target's package and reference it by its bare local +// name, not a partially package-qualified name. +enum ProviderType { + PROVIDER_TYPE_UNSPECIFIED = 0; + PROVIDER_TYPE_OPENAI = 1; + PROVIDER_TYPE_ANTHROPIC = 2; +} diff --git a/protoc-gen-go-sr-normalize/example/proto/example/v1/event.proto b/protoc-gen-go-sr-normalize/example/proto/example/v1/event.proto new file mode 100644 index 0000000..b4b75bc --- /dev/null +++ b/protoc-gen-go-sr-normalize/example/proto/example/v1/event.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package example.v1; + +import "example/other/v1/provider.proto"; +import "redpanda/api/common/v1/schema_registry.proto"; + +option go_package = "github.com/redpanda-data/common-go/protoc-gen-go-sr/example/gen/go/example/v1;examplev1"; + +// Event references ProviderType from a different proto package +// (example.other.v1). The generated schema must inline ProviderType as a +// top-level enum and reference it by its local name "ProviderType" -- not +// "other.v1.ProviderType", which would be a dangling reference that Schema +// Registry rejects. +message Event { + option (redpanda.api.common.v1.schema_registry) = { + subject: "example.events-value" + }; + + string id = 1; + example.other.v1.ProviderType provider = 2; +} diff --git a/protoc-gen-go-sr-normalize/main.go b/protoc-gen-go-sr-normalize/main.go index a7174e7..3b33217 100644 --- a/protoc-gen-go-sr-normalize/main.go +++ b/protoc-gen-go-sr-normalize/main.go @@ -311,35 +311,49 @@ func renderEnum(b *strings.Builder, enum *protogen.Enum, indent string) { b.WriteString(fmt.Sprintf("%s}\n\n", indent)) } -// fieldTypeName returns the proto type name for a field. +// fieldTypeName returns the proto type name for a field, as referenced from +// within the generated schema. func fieldTypeName(field *protogen.Field) string { if field.Enum != nil { - return relativeName(field.Enum.Desc.FullName(), field.Desc.Parent().(protoreflect.MessageDescriptor).FullName()) + return schemaTypeName(field.Enum.Desc) } if field.Message != nil { - return relativeName(field.Message.Desc.FullName(), field.Desc.Parent().(protoreflect.MessageDescriptor).FullName()) + return schemaTypeName(field.Message.Desc) } return field.Desc.Kind().String() } -// relativeName returns a type name relative to the parent scope. -// If both are in the same package, returns just the local name. -func relativeName(target, parent protoreflect.FullName) string { - // Strip shared package prefix. - targetParts := strings.Split(string(target), ".") - parentParts := strings.Split(string(parent), ".") - - // Find common prefix length (package parts). - common := 0 - for i := 0; i < len(targetParts)-1 && i < len(parentParts)-1; i++ { - if targetParts[i] == parentParts[i] { - common = i + 1 - } else { - break - } +// schemaTypeName returns the name a type is referenced by in the generated +// schema. +// +// Well-known types (google/protobuf/*) are kept as imports rather than inlined, +// so they keep their full name (e.g. google.protobuf.Timestamp). Every other +// type is inlined into the target's single package, so it is referenced by its +// local name -- see localTypeName. +func schemaTypeName(desc protoreflect.Descriptor) string { + if isWellKnown(desc.FullName()) { + return string(desc.FullName()) } + return localTypeName(desc.FullName(), desc.ParentFile().Package()) +} - return strings.Join(targetParts[common:], ".") +// localTypeName returns a type's path within its own file: its fully qualified +// name with its own package prefix stripped. A top-level type yields its bare +// name (e.g. "ProductStatus"); a nested type yields "Parent.Nested". +// +// The generator inlines every transitive dependency into the target's single +// package while preserving message nesting, so this within-file path is exactly +// how the type is rendered in the output schema -- regardless of which package +// the referring field lives in. Stripping the type's OWN package (rather than +// only the prefix it shares with the referrer) is what makes cross-package +// dependencies resolve: a type from another package is inlined top-level under +// its bare name, and this produces that bare name instead of a dangling, +// partially-qualified reference. +func localTypeName(full, pkg protoreflect.FullName) string { + if pkg == "" { + return string(full) + } + return strings.TrimPrefix(string(full), string(pkg)+".") } func getSROptions(msg *protogen.Message) (subject string, ok bool) { diff --git a/protoc-gen-go-sr-normalize/sr_test.go b/protoc-gen-go-sr-normalize/sr_test.go index 9eebb44..a4a7c2d 100644 --- a/protoc-gen-go-sr-normalize/sr_test.go +++ b/protoc-gen-go-sr-normalize/sr_test.go @@ -25,9 +25,11 @@ import ( "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" "github.com/twmb/franz-go/pkg/sr" + "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/known/timestamppb" commonkvstore "github.com/redpanda-data/common-go/kvstore" + otherv1 "github.com/redpanda-data/common-go/protoc-gen-go-sr-normalize/example/gen/go/example/other/v1" examplev1 "github.com/redpanda-data/common-go/protoc-gen-go-sr-normalize/example/gen/go/example/v1" ) @@ -58,6 +60,13 @@ func TestGeneratedSchemas_SelfContained(t *testing.T) { wantMessages: []string{"Order", "OrderItem", "Product"}, wantEnums: []string{"OrderStatus", "ProductStatus"}, }, + { + name: "Event with cross-package dep", + schema: examplev1.EventSRSchema, + subject: examplev1.EventSRSubject, + wantMessages: []string{"Event"}, + wantEnums: []string{"ProviderType"}, + }, } for _, tt := range tests { @@ -118,6 +127,46 @@ func TestGeneratedSchemas_Independent(t *testing.T) { assert.NotContains(t, examplev1.OrderSRSchema, "GetProduct") } +// TestGeneratedSchemas_CrossPackage verifies that a dependency from a DIFFERENT +// proto package (example.other.v1.ProviderType, referenced by example.v1.Event) +// is inlined as a top-level enum and referenced by its bare local name. A +// partially package-qualified reference like "other.v1.ProviderType" is a +// dangling type that Schema Registry rejects. +func TestGeneratedSchemas_CrossPackage(t *testing.T) { + schema := examplev1.EventSRSchema + + // The cross-package enum is inlined top-level and referenced by local name. + assert.Contains(t, schema, "enum ProviderType {") + assert.Contains(t, schema, "ProviderType provider = 2;") + + // No dangling, partially package-qualified reference may survive. + assert.NotContains(t, schema, "other.v1.ProviderType") + assert.NotContains(t, schema, "example.other") +} + +// TestLocalTypeName covers the reference-naming logic directly. The cross-package +// case is the regression: the referred type's own package must be stripped, not +// just the prefix it shares with the referrer. +func TestLocalTypeName(t *testing.T) { + tests := []struct { + name string + full string + pkg string + want string + }{ + {"same package top-level", "example.v1.ProductStatus", "example.v1", "ProductStatus"}, + {"cross package top-level", "example.other.v1.ProviderType", "example.other.v1", "ProviderType"}, + {"nested type keeps parent path", "example.v1.Product.Category", "example.v1", "Product.Category"}, + {"empty package returns full name", "TopLevel", "", "TopLevel"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := localTypeName(protoreflect.FullName(tt.full), protoreflect.FullName(tt.pkg)) + assert.Equal(t, tt.want, got) + }) + } +} + func startRedpanda(t *testing.T) *sr.Client { t.Helper() if testing.Short() { @@ -225,6 +274,34 @@ func TestSchemaRegistry_OrderRoundTrip(t *testing.T) { assert.Equal(t, int32(3), decoded.GetItems()[0].GetQuantity()) } +// TestSchemaRegistry_EventRoundTrip tests the Event schema, whose ProviderType +// enum dependency lives in a different proto package (example.other.v1). +// Registration fails against a real Schema Registry if the inlined enum is +// referenced by a dangling, partially package-qualified name. +func TestSchemaRegistry_EventRoundTrip(t *testing.T) { + srClient := startRedpanda(t) + + serde, err := commonkvstore.Proto( + func() *examplev1.Event { return &examplev1.Event{} }, + commonkvstore.WithSchemaRegistry(srClient, examplev1.EventSRSubject, examplev1.EventSRSchema), + ) + require.NoError(t, err) + + original := &examplev1.Event{ + Id: "evt-1", + Provider: otherv1.ProviderType_PROVIDER_TYPE_ANTHROPIC, + } + + data, err := serde.Serialize(original) + require.NoError(t, err) + assert.Equal(t, byte(0x00), data[0]) + + decoded, err := serde.Deserialize(data) + require.NoError(t, err) + assert.Equal(t, original.GetId(), decoded.GetId()) + assert.Equal(t, original.GetProvider(), decoded.GetProvider()) +} + // TestSchemaRegistry_BothSchemasCoexist registers both Product and Order schemas // on the same SR instance and verifies they don't interfere with each other. func TestSchemaRegistry_BothSchemasCoexist(t *testing.T) {