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) {