From 0b760151eba70494563130f26696bd4b483eb190 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 5 May 2026 23:03:17 -0400 Subject: [PATCH 1/3] feat(template): derive ErrorTemplate from proto Template option Signed-off-by: Yordis Prieto --- error.go | 5 + go.mod | 9 +- go.sum | 8 ++ template_proto.go | 233 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 template_proto.go diff --git a/error.go b/error.go index b76f30e..84af3d6 100644 --- a/error.go +++ b/error.go @@ -795,6 +795,8 @@ type ErrorTemplate struct { message string // empty string means use code's default message visibility Visibility help *Help + metadata Metadata + fields []protoFieldSpec } // TemplateOption represents options that can be applied to ErrorTemplate @@ -866,6 +868,9 @@ func (et *ErrorTemplate) NewError(options ...ErrorOption) *TrogonError { if et.help != nil { baseOptions = append(baseOptions, WithHelp(*et.help)) } + for key, value := range et.metadata { + baseOptions = append(baseOptions, WithMetadataValue(value.visibility, key, value.value)) + } return NewError(et.domain, et.reason, append(baseOptions, options...)...) } diff --git a/go.mod b/go.mod index eddc872..886cfdd 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,15 @@ module github.com/TrogonStack/trogonerror -go 1.24.2 +go 1.26 -require github.com/stretchr/testify v1.11.1 +require ( + github.com/TrogonStack/trogonproto v0.1.0 + github.com/stretchr/testify v1.11.1 + google.golang.org/protobuf v1.36.11 +) require ( + buf.build/gen/go/elixir-protobuf/protobuf/protocolbuffers/go v1.36.11-20260114033128-d590dff383e9.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index c4c1710..bc6df52 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,17 @@ +buf.build/gen/go/elixir-protobuf/protobuf/protocolbuffers/go v1.36.11-20260114033128-d590dff383e9.1 h1:YyaDi4kIyhOWTWYN0OzoKC+Wo+DpSC5aQxfe69fVUWA= +buf.build/gen/go/elixir-protobuf/protobuf/protocolbuffers/go v1.36.11-20260114033128-d590dff383e9.1/go.mod h1:fQtoq8UKgFiCFI3I7uyYxA2iVU1CplNN0PJpdFLdXHI= +github.com/TrogonStack/trogonproto v0.1.0 h1:l3Skhw6rY4zskjS2d8GfXK9fKB6n5lIKObksAjknpUg= +github.com/TrogonStack/trogonproto v0.1.0/go.mod h1:RfEvGMmPYqH8RLE1I63nJZ3OmLUbBa4XSjya06aPLVE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/template_proto.go b/template_proto.go new file mode 100644 index 0000000..fdf82d0 --- /dev/null +++ b/template_proto.go @@ -0,0 +1,233 @@ +package trogonerror + +import ( + "fmt" + + errpb "github.com/TrogonStack/trogonproto/gen/trogon/error/v1alpha1" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +type protoFieldSpec struct { + key string + number protoreflect.FieldNumber + visibility Visibility + hasFixedValue bool + fixedValue string + hasDefault bool + defaultValue string +} + +// NewErrorTemplateFromProto builds an ErrorTemplate from a proto message type +// that carries the trogon.error.v1alpha1.Template message option. +// +// The descriptor is read once at template-construction time. Per-field +// FieldOptions are cached so subsequent FromProto calls do not re-walk the +// descriptor. +func NewErrorTemplateFromProto[T proto.Message](options ...TemplateOption) *ErrorTemplate { + var zero T + desc := zero.ProtoReflect().Descriptor() + + template := &ErrorTemplate{ + code: CodeUnknown, + visibility: VisibilityInternal, + } + + if msgOpts, ok := proto.GetExtension(desc.Options(), errpb.E_Message).(*errpb.MessageOptions); ok && msgOpts != nil { + applyProtoTemplate(template, msgOpts.GetTemplate()) + } + + template.fields = collectFieldSpecs(desc) + for _, spec := range template.fields { + if spec.hasFixedValue { + if template.metadata == nil { + template.metadata = Metadata{} + } + template.metadata[spec.key] = MetadataValue{ + value: spec.fixedValue, + visibility: spec.visibility, + } + } + } + + for _, option := range options { + option(template) + } + + return template +} + +// FromProto creates a new error instance, deriving metadata from the proto +// message's populated fields according to their FieldOptions annotations. +// +// Fields with value_policy = value contribute their fixed literal (already +// baked into the template). Fields with value_policy = default_value use the +// runtime field value when set, falling back to default_value otherwise. +// Fields without a value policy use the runtime field value as-is. +// +// Caller-supplied options apply last and override anything derived from the +// proto instance. +func (et *ErrorTemplate) FromProto(m proto.Message, options ...ErrorOption) *TrogonError { + derived := make([]ErrorOption, 0, len(et.fields)) + reflected := m.ProtoReflect() + + for _, spec := range et.fields { + if spec.hasFixedValue { + continue + } + + field := reflected.Descriptor().Fields().ByNumber(spec.number) + if field == nil { + continue + } + + value := protoFieldString(reflected, field) + if value == "" && spec.hasDefault { + value = spec.defaultValue + } + if value == "" { + continue + } + + derived = append(derived, WithMetadataValue(spec.visibility, spec.key, value)) + } + + return et.NewError(append(derived, options...)...) +} + +func applyProtoTemplate(template *ErrorTemplate, t *errpb.MessageOptions_Template) { + if t == nil { + return + } + if d := t.GetDomain(); d != "" { + template.domain = d + } + if r := t.GetReason(); r != "" { + template.reason = r + } + if m := t.GetMessage(); m != "" { + template.message = m + } + if c := t.GetCode(); c != errpb.Code_UNSPECIFIED { + template.code = mapProtoCode(c) + } + if v := t.GetVisibility(); v != errpb.Visibility_VISIBILITY_UNSPECIFIED { + template.visibility = mapProtoVisibility(v) + } + for _, link := range t.GetHelpLinks() { + if template.help == nil { + template.help = &Help{} + } + template.help.links = append(template.help.links, HelpLink{ + description: link.GetDescription(), + url: link.GetUrl(), + }) + } + for _, entry := range t.GetMetadata() { + if entry.GetKey() == "" { + continue + } + if template.metadata == nil { + template.metadata = Metadata{} + } + template.metadata[entry.GetKey()] = MetadataValue{ + value: entry.GetValue(), + visibility: mapProtoVisibility(entry.GetVisibility()), + } + } +} + +func collectFieldSpecs(desc protoreflect.MessageDescriptor) []protoFieldSpec { + fields := desc.Fields() + specs := make([]protoFieldSpec, 0, fields.Len()) + for i := 0; i < fields.Len(); i++ { + field := fields.Get(i) + fopts, ok := proto.GetExtension(field.Options(), errpb.E_Field).(*errpb.FieldOptions) + if !ok || fopts == nil { + continue + } + spec := protoFieldSpec{ + key: field.JSONName(), + number: field.Number(), + visibility: mapProtoVisibility(fopts.GetVisibility()), + } + switch policy := fopts.GetValuePolicy().(type) { + case *errpb.FieldOptions_Value: + spec.hasFixedValue = true + spec.fixedValue = policy.Value + case *errpb.FieldOptions_DefaultValue: + spec.hasDefault = true + spec.defaultValue = policy.DefaultValue + } + specs = append(specs, spec) + } + return specs +} + +func protoFieldString(m protoreflect.Message, field protoreflect.FieldDescriptor) string { + if !m.Has(field) { + return "" + } + v := m.Get(field) + switch field.Kind() { + case protoreflect.StringKind: + return v.String() + case protoreflect.EnumKind: + if ev := field.Enum().Values().ByNumber(v.Enum()); ev != nil { + return string(ev.Name()) + } + return fmt.Sprint(int32(v.Enum())) + default: + return fmt.Sprint(v.Interface()) + } +} + +func mapProtoCode(c errpb.Code) Code { + switch c { + case errpb.Code_CANCELLED: + return CodeCancelled + case errpb.Code_UNKNOWN: + return CodeUnknown + case errpb.Code_INVALID_ARGUMENT: + return CodeInvalidArgument + case errpb.Code_DEADLINE_EXCEEDED: + return CodeDeadlineExceeded + case errpb.Code_NOT_FOUND: + return CodeNotFound + case errpb.Code_ALREADY_EXISTS: + return CodeAlreadyExists + case errpb.Code_PERMISSION_DENIED: + return CodePermissionDenied + case errpb.Code_RESOURCE_EXHAUSTED: + return CodeResourceExhausted + case errpb.Code_FAILED_PRECONDITION: + return CodeFailedPrecondition + case errpb.Code_ABORTED: + return CodeAborted + case errpb.Code_OUT_OF_RANGE: + return CodeOutOfRange + case errpb.Code_UNIMPLEMENTED: + return CodeUnimplemented + case errpb.Code_INTERNAL: + return CodeInternal + case errpb.Code_UNAVAILABLE: + return CodeUnavailable + case errpb.Code_DATA_LOSS: + return CodeDataLoss + case errpb.Code_UNAUTHENTICATED: + return CodeUnauthenticated + default: + return CodeUnknown + } +} + +func mapProtoVisibility(v errpb.Visibility) Visibility { + switch v { + case errpb.Visibility_VISIBILITY_PUBLIC: + return VisibilityPublic + case errpb.Visibility_VISIBILITY_PRIVATE: + return VisibilityPrivate + default: + return VisibilityInternal + } +} From 5892ea6aaeae4fa755d18f419aa9d2283b8dba1e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 5 May 2026 23:08:12 -0400 Subject: [PATCH 2/3] test(template): cover proto-derived template with sample fixture Signed-off-by: Yordis Prieto --- .gitattributes | 3 + .github/workflows/ci.yml | 20 +- buf.gen.yaml | 25 +++ example_test.go | 25 +++ .../gen/trogonerror/testdata/v1/errors.pb.go | 184 ++++++++++++++++++ internal/testdata/proto/buf.lock | 12 ++ internal/testdata/proto/buf.yaml | 6 + .../trogonerror/testdata/v1/errors.proto | 34 ++++ template_proto_test.go | 91 +++++++++ 9 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 buf.gen.yaml create mode 100644 internal/testdata/gen/trogonerror/testdata/v1/errors.pb.go create mode 100644 internal/testdata/proto/buf.lock create mode 100644 internal/testdata/proto/buf.yaml create mode 100644 internal/testdata/proto/trogonerror/testdata/v1/errors.proto create mode 100644 template_proto_test.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c85eac3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +internal/testdata/gen/** linguist-generated=true +*.pb.go linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb652ed..75c741f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,12 +12,30 @@ permissions: pull-requests: read jobs: + generated-code-up-to-date: + name: Generated Code Up-To-Date + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5.0.0 + - uses: bufbuild/buf-setup-action@v1.50.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Regenerate Go bindings + run: buf generate + - name: Verify clean working tree + run: | + if ! git diff --quiet --exit-code; then + echo "Generated code is out of date. Run 'buf generate' and commit the result." + git diff + exit 1 + fi + quality-assurance: name: Quality Assurance runs-on: ubuntu-latest strategy: matrix: - go-version: [1.24.x] + go-version: [1.26.x] steps: - uses: actions/checkout@v5.0.0 diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..d1a136f --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,25 @@ +version: v2 +managed: + enabled: true + override: + - file_option: go_package_prefix + value: github.com/TrogonStack/trogonerror/internal/testdata/gen + - file_option: go_package + path: trogon/error/v1alpha1/code.proto + value: github.com/TrogonStack/trogonproto/gen/trogon/error/v1alpha1 + - file_option: go_package + path: trogon/error/v1alpha1/options.proto + value: github.com/TrogonStack/trogonproto/gen/trogon/error/v1alpha1 + - file_option: go_package + path: trogon/error/v1alpha1/visibility.proto + value: github.com/TrogonStack/trogonproto/gen/trogon/error/v1alpha1 + - file_option: go_package + path: elixirpb.proto + value: buf.build/gen/go/elixir-protobuf/protobuf/protocolbuffers/go +plugins: + - remote: buf.build/protocolbuffers/go:v1.36.5 + out: internal/testdata/gen + opt: + - paths=source_relative +inputs: + - directory: internal/testdata/proto diff --git a/example_test.go b/example_test.go index 84be37f..2920865 100644 --- a/example_test.go +++ b/example_test.go @@ -5,8 +5,33 @@ import ( "time" "github.com/TrogonStack/trogonerror" + testdatav1 "github.com/TrogonStack/trogonerror/internal/testdata/gen/trogonerror/testdata/v1" ) +var userNotFoundTpl = trogonerror.NewErrorTemplateFromProto[*testdatav1.UserNotFound]() + +func ExampleNewErrorTemplateFromProto() { + err := userNotFoundTpl.FromProto(&testdatav1.UserNotFound{ + UserId: "gid://shopify/Customer/1234567890", + }) + + fmt.Println(err.Error()) + // Output: + // User does not exist. + // visibility: PUBLIC + // domain: shopify.users + // reason: USER_NOT_FOUND + // code: NOT_FOUND + // metadata: + // - component: users visibility=PUBLIC + // - region: us-east-1 visibility=PUBLIC + // - team: platform-identity visibility=INTERNAL + // - tenantId: default-tenant visibility=PUBLIC + // - userId: gid://shopify/Customer/1234567890 visibility=PUBLIC + // + // - User Docs: https://docs.shopify.com/users +} + func ExampleNewError() { err := trogonerror.NewError("shopify.users", "NOT_FOUND", trogonerror.WithCode(trogonerror.CodeNotFound), diff --git a/internal/testdata/gen/trogonerror/testdata/v1/errors.pb.go b/internal/testdata/gen/trogonerror/testdata/v1/errors.pb.go new file mode 100644 index 0000000..6c51380 --- /dev/null +++ b/internal/testdata/gen/trogonerror/testdata/v1/errors.pb.go @@ -0,0 +1,184 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.5 +// protoc (unknown) +// source: trogonerror/testdata/v1/errors.proto + +package testdatav1 + +import ( + _ "github.com/TrogonStack/trogonproto/gen/trogon/error/v1alpha1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +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) +) + +type UserNotFound struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + TenantId string `protobuf:"bytes,2,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + Region string `protobuf:"bytes,3,opt,name=region,proto3" json:"region,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserNotFound) Reset() { + *x = UserNotFound{} + mi := &file_trogonerror_testdata_v1_errors_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserNotFound) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserNotFound) ProtoMessage() {} + +func (x *UserNotFound) ProtoReflect() protoreflect.Message { + mi := &file_trogonerror_testdata_v1_errors_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 UserNotFound.ProtoReflect.Descriptor instead. +func (*UserNotFound) Descriptor() ([]byte, []int) { + return file_trogonerror_testdata_v1_errors_proto_rawDescGZIP(), []int{0} +} + +func (x *UserNotFound) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *UserNotFound) GetTenantId() string { + if x != nil { + return x.TenantId + } + return "" +} + +func (x *UserNotFound) GetRegion() string { + if x != nil { + return x.Region + } + return "" +} + +var File_trogonerror_testdata_v1_errors_proto protoreflect.FileDescriptor + +var file_trogonerror_testdata_v1_errors_proto_rawDesc = string([]byte{ + 0x0a, 0x24, 0x74, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2f, 0x74, 0x65, + 0x73, 0x74, 0x64, 0x61, 0x74, 0x61, 0x2f, 0x76, 0x31, 0x2f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x17, 0x74, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x2e, 0x74, 0x65, 0x73, 0x74, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x76, 0x31, 0x1a, + 0x20, 0x74, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x2f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2f, 0x76, 0x31, + 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x1a, 0x23, 0x74, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x2f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2f, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x26, 0x74, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x2f, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x76, 0x69, + 0x73, 0x69, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xb7, + 0x02, 0x0a, 0x0c, 0x55, 0x73, 0x65, 0x72, 0x4e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x12, + 0x20, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x07, 0xea, 0xe7, 0xa8, 0x03, 0x02, 0x08, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, + 0x64, 0x12, 0x34, 0x0a, 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x17, 0xea, 0xe7, 0xa8, 0x03, 0x12, 0x08, 0x03, 0x12, 0x0e, 0x64, + 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x2d, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x52, 0x08, 0x74, + 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, + 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x12, 0xea, 0xe7, 0xa8, 0x03, 0x0d, 0x08, 0x03, + 0x1a, 0x09, 0x75, 0x73, 0x2d, 0x65, 0x61, 0x73, 0x74, 0x2d, 0x31, 0x52, 0x06, 0x72, 0x65, 0x67, + 0x69, 0x6f, 0x6e, 0x3a, 0xa2, 0x01, 0xe2, 0xe7, 0xa8, 0x03, 0x9c, 0x01, 0x0a, 0x99, 0x01, 0x0a, + 0x0d, 0x73, 0x68, 0x6f, 0x70, 0x69, 0x66, 0x79, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x12, 0x0e, + 0x55, 0x53, 0x45, 0x52, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x46, 0x4f, 0x55, 0x4e, 0x44, 0x1a, 0x14, + 0x55, 0x73, 0x65, 0x72, 0x20, 0x64, 0x6f, 0x65, 0x73, 0x20, 0x6e, 0x6f, 0x74, 0x20, 0x65, 0x78, + 0x69, 0x73, 0x74, 0x2e, 0x20, 0x05, 0x28, 0x03, 0x32, 0x2b, 0x0a, 0x1e, 0x68, 0x74, 0x74, 0x70, + 0x73, 0x3a, 0x2f, 0x2f, 0x64, 0x6f, 0x63, 0x73, 0x2e, 0x73, 0x68, 0x6f, 0x70, 0x69, 0x66, 0x79, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x12, 0x09, 0x55, 0x73, 0x65, 0x72, + 0x20, 0x44, 0x6f, 0x63, 0x73, 0x3a, 0x14, 0x0a, 0x09, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, + 0x6e, 0x74, 0x12, 0x05, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x3a, 0x1b, 0x0a, 0x04, 0x74, + 0x65, 0x61, 0x6d, 0x12, 0x11, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x2d, 0x69, 0x64, + 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x42, 0x85, 0x02, 0x0a, 0x1b, 0x63, 0x6f, 0x6d, + 0x2e, 0x74, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2e, 0x74, 0x65, 0x73, + 0x74, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x76, 0x31, 0x42, 0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, + 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x5b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x54, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x63, 0x6b, 0x2f, + 0x74, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x73, 0x74, 0x64, 0x61, 0x74, 0x61, 0x2f, 0x67, 0x65, + 0x6e, 0x2f, 0x74, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2f, 0x74, 0x65, + 0x73, 0x74, 0x64, 0x61, 0x74, 0x61, 0x2f, 0x76, 0x31, 0x3b, 0x74, 0x65, 0x73, 0x74, 0x64, 0x61, + 0x74, 0x61, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x54, 0x54, 0x58, 0xaa, 0x02, 0x17, 0x54, 0x72, 0x6f, + 0x67, 0x6f, 0x6e, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x64, 0x61, 0x74, + 0x61, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x17, 0x54, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x65, 0x72, 0x72, + 0x6f, 0x72, 0x5c, 0x54, 0x65, 0x73, 0x74, 0x64, 0x61, 0x74, 0x61, 0x5c, 0x56, 0x31, 0xe2, 0x02, + 0x23, 0x54, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5c, 0x54, 0x65, 0x73, + 0x74, 0x64, 0x61, 0x74, 0x61, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x19, 0x54, 0x72, 0x6f, 0x67, 0x6f, 0x6e, 0x65, 0x72, 0x72, + 0x6f, 0x72, 0x3a, 0x3a, 0x54, 0x65, 0x73, 0x74, 0x64, 0x61, 0x74, 0x61, 0x3a, 0x3a, 0x56, 0x31, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_trogonerror_testdata_v1_errors_proto_rawDescOnce sync.Once + file_trogonerror_testdata_v1_errors_proto_rawDescData []byte +) + +func file_trogonerror_testdata_v1_errors_proto_rawDescGZIP() []byte { + file_trogonerror_testdata_v1_errors_proto_rawDescOnce.Do(func() { + file_trogonerror_testdata_v1_errors_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_trogonerror_testdata_v1_errors_proto_rawDesc), len(file_trogonerror_testdata_v1_errors_proto_rawDesc))) + }) + return file_trogonerror_testdata_v1_errors_proto_rawDescData +} + +var file_trogonerror_testdata_v1_errors_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_trogonerror_testdata_v1_errors_proto_goTypes = []any{ + (*UserNotFound)(nil), // 0: trogonerror.testdata.v1.UserNotFound +} +var file_trogonerror_testdata_v1_errors_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_trogonerror_testdata_v1_errors_proto_init() } +func file_trogonerror_testdata_v1_errors_proto_init() { + if File_trogonerror_testdata_v1_errors_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_trogonerror_testdata_v1_errors_proto_rawDesc), len(file_trogonerror_testdata_v1_errors_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_trogonerror_testdata_v1_errors_proto_goTypes, + DependencyIndexes: file_trogonerror_testdata_v1_errors_proto_depIdxs, + MessageInfos: file_trogonerror_testdata_v1_errors_proto_msgTypes, + }.Build() + File_trogonerror_testdata_v1_errors_proto = out.File + file_trogonerror_testdata_v1_errors_proto_goTypes = nil + file_trogonerror_testdata_v1_errors_proto_depIdxs = nil +} diff --git a/internal/testdata/proto/buf.lock b/internal/testdata/proto/buf.lock new file mode 100644 index 0000000..9114efb --- /dev/null +++ b/internal/testdata/proto/buf.lock @@ -0,0 +1,12 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/elixir-protobuf/protobuf + commit: d590dff383e94f28bacec0e8015c4daa + digest: b5:fa4a17975e0ed6b54e3e7637d659a6358fb18ebd8a931462003cea62d43336fb02c8685076e4cb5129875b13f13c2c6dfe7205f2215d7666efdfc5cce836a2de + - name: buf.build/protocolbuffers/wellknowntypes + commit: 4e1ccfa6827947beb55974645a315b8d + digest: b5:eb5228b1abd02064d6ff0248918500c1ec1ce7df69126af3f220c0b67d81ff45bdf9f016a8e66cd9c1e534f18afc6d8e090d400604c5331d551a68d05f7e7be9 + - name: buf.build/trogonstack/trogon-proto + commit: e7442b2c978348c9864565ec086db7a5 + digest: b5:2dba0ce198c5981e5d870610f5a5eaadf499d28bffb9d34fd4df5decc35ac5cbcedb670eeaf431312ab59e6d4050f7eb5559a5715e3db06322033658269468ef diff --git a/internal/testdata/proto/buf.yaml b/internal/testdata/proto/buf.yaml new file mode 100644 index 0000000..2b937ab --- /dev/null +++ b/internal/testdata/proto/buf.yaml @@ -0,0 +1,6 @@ +version: v2 +modules: + - path: . +deps: + - buf.build/trogonstack/trogon-proto + - buf.build/elixir-protobuf/protobuf diff --git a/internal/testdata/proto/trogonerror/testdata/v1/errors.proto b/internal/testdata/proto/trogonerror/testdata/v1/errors.proto new file mode 100644 index 0000000..79bd77a --- /dev/null +++ b/internal/testdata/proto/trogonerror/testdata/v1/errors.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package trogonerror.testdata.v1; + +import "trogon/error/v1alpha1/code.proto"; +import "trogon/error/v1alpha1/options.proto"; +import "trogon/error/v1alpha1/visibility.proto"; + +message UserNotFound { + option (trogon.error.v1alpha1.message).template = { + domain: "shopify.users", + reason: "USER_NOT_FOUND", + message: "User does not exist.", + code: NOT_FOUND, + visibility: VISIBILITY_PUBLIC, + help_links: [{url: "https://docs.shopify.com/users", description: "User Docs"}], + metadata: [ + {key: "component", value: "users", visibility: VISIBILITY_PUBLIC}, + {key: "team", value: "platform-identity", visibility: VISIBILITY_INTERNAL} + ] + }; + + string user_id = 1 [(trogon.error.v1alpha1.field) = { + visibility: VISIBILITY_PUBLIC + }]; + string tenant_id = 2 [(trogon.error.v1alpha1.field) = { + visibility: VISIBILITY_PUBLIC, + default_value: "default-tenant" + }]; + string region = 3 [(trogon.error.v1alpha1.field) = { + visibility: VISIBILITY_PUBLIC, + value: "us-east-1" + }]; +} diff --git a/template_proto_test.go b/template_proto_test.go new file mode 100644 index 0000000..69dfb1e --- /dev/null +++ b/template_proto_test.go @@ -0,0 +1,91 @@ +package trogonerror_test + +import ( + "testing" + + "github.com/TrogonStack/trogonerror" + testdatav1 "github.com/TrogonStack/trogonerror/internal/testdata/gen/trogonerror/testdata/v1" + "github.com/stretchr/testify/assert" +) + +var userNotFoundTemplate = trogonerror.NewErrorTemplateFromProto[*testdatav1.UserNotFound]() + +func TestNewErrorTemplateFromProto_TemplateLevel(t *testing.T) { + err := userNotFoundTemplate.NewError() + + assert.Equal(t, "shopify.users", err.Domain()) + assert.Equal(t, "USER_NOT_FOUND", err.Reason()) + assert.Equal(t, trogonerror.CodeNotFound, err.Code()) + assert.Equal(t, "User does not exist.", err.Message()) + assert.Equal(t, trogonerror.VisibilityPublic, err.Visibility()) + + help := err.Help() + if assert.NotNil(t, help) { + assert.Equal(t, "https://docs.shopify.com/users", help.Links()[0].URL()) + } +} + +func TestNewErrorTemplateFromProto_TemplateMetadata(t *testing.T) { + md := userNotFoundTemplate.NewError().Metadata() + + assert.Equal(t, "users", md["component"].Value()) + assert.Equal(t, trogonerror.VisibilityPublic, md["component"].Visibility()) + assert.Equal(t, "platform-identity", md["team"].Value()) + assert.Equal(t, trogonerror.VisibilityInternal, md["team"].Visibility()) +} + +func TestNewErrorTemplateFromProto_FieldFixedValueBaked(t *testing.T) { + md := userNotFoundTemplate.NewError().Metadata() + + assert.Equal(t, "us-east-1", md["region"].Value(), "value_policy=value should bake into template") + assert.Equal(t, trogonerror.VisibilityPublic, md["region"].Visibility()) +} + +func TestErrorTemplate_FromProto_RuntimeFieldValues(t *testing.T) { + err := userNotFoundTemplate.FromProto(&testdatav1.UserNotFound{ + UserId: "gid://shopify/Customer/1234", + TenantId: "acme", + }) + + md := err.Metadata() + assert.Equal(t, "gid://shopify/Customer/1234", md["userId"].Value()) + assert.Equal(t, "acme", md["tenantId"].Value()) + assert.Equal(t, "us-east-1", md["region"].Value()) +} + +func TestErrorTemplate_FromProto_DefaultValueFallback(t *testing.T) { + err := userNotFoundTemplate.FromProto(&testdatav1.UserNotFound{ + UserId: "gid://shopify/Customer/1234", + }) + + md := err.Metadata() + assert.Equal(t, "default-tenant", md["tenantId"].Value(), "empty field should fall back to default_value") +} + +func TestErrorTemplate_FromProto_FixedValueWinsOverRuntime(t *testing.T) { + err := userNotFoundTemplate.FromProto(&testdatav1.UserNotFound{ + UserId: "gid://shopify/Customer/1234", + Region: "ignored", + }) + + assert.Equal(t, "us-east-1", err.Metadata()["region"].Value(), + "value_policy=value must ignore runtime field") +} + +func TestErrorTemplate_FromProto_OptionsOverride(t *testing.T) { + err := userNotFoundTemplate.FromProto( + &testdatav1.UserNotFound{UserId: "u1"}, + trogonerror.WithMetadataValue(trogonerror.VisibilityPublic, "userId", "u2"), + ) + + assert.Equal(t, "u2", err.Metadata()["userId"].Value(), + "caller options must win over proto-derived metadata") +} + +func TestNewErrorTemplateFromProto_TemplateOptionOverride(t *testing.T) { + template := trogonerror.NewErrorTemplateFromProto[*testdatav1.UserNotFound]( + trogonerror.TemplateWithMessage("custom message"), + ) + + assert.Equal(t, "custom message", template.NewError().Message()) +} From 6191c3f441766e696961f1f64a3a7c3342f0c84f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 5 May 2026 23:09:57 -0400 Subject: [PATCH 3/3] chore(buf): collapse trogon-proto overrides and drop unused elixirpb mapping Signed-off-by: Yordis Prieto --- buf.gen.yaml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/buf.gen.yaml b/buf.gen.yaml index d1a136f..d326f2c 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -4,18 +4,9 @@ managed: override: - file_option: go_package_prefix value: github.com/TrogonStack/trogonerror/internal/testdata/gen - - file_option: go_package - path: trogon/error/v1alpha1/code.proto - value: github.com/TrogonStack/trogonproto/gen/trogon/error/v1alpha1 - - file_option: go_package - path: trogon/error/v1alpha1/options.proto - value: github.com/TrogonStack/trogonproto/gen/trogon/error/v1alpha1 - - file_option: go_package - path: trogon/error/v1alpha1/visibility.proto - value: github.com/TrogonStack/trogonproto/gen/trogon/error/v1alpha1 - - file_option: go_package - path: elixirpb.proto - value: buf.build/gen/go/elixir-protobuf/protobuf/protocolbuffers/go + - file_option: go_package_prefix + module: buf.build/trogonstack/trogon-proto + value: github.com/TrogonStack/trogonproto/gen plugins: - remote: buf.build/protocolbuffers/go:v1.36.5 out: internal/testdata/gen