Repository navigation
feat(template): derive ErrorTemplate from proto Template option #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f906c0e
b21328f
d25ae72
2462f11
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| * text=auto eol=lf | ||
| internal/testdata/gen/** linguist-generated=true | ||
| *.pb.go linguist-generated=true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| version: v2 | ||
| managed: | ||
| enabled: true | ||
| override: | ||
| - file_option: go_package_prefix | ||
| value: github.com/TrogonStack/trogonerror/internal/testdata/gen | ||
| - 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 | ||
| opt: | ||
| - paths=source_relative | ||
| inputs: | ||
| - directory: internal/testdata/proto |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package errproto_test | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/TrogonStack/trogonerror/errproto" | ||
| testdatav1 "github.com/TrogonStack/trogonerror/internal/testdata/gen/trogonerror/testdata/v1" | ||
| ) | ||
|
|
||
| var userNotFoundTpl = errproto.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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| // Package errproto derives trogonerror.ErrorTemplate values from proto | ||
| // messages that carry the trogon.error.v1alpha1 Template and FieldOptions | ||
| // message/field options, so services can declare their error contract once | ||
| // in proto and pick it up in Go without hand-rewriting the same | ||
| // domain/reason/code/visibility/help/metadata in two places. | ||
| package errproto | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/TrogonStack/trogonerror" | ||
| 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 trogonerror.Visibility | ||
| hasFixedValue bool | ||
| fixedValue string | ||
| hasDefault bool | ||
| defaultValue string | ||
| } | ||
|
|
||
| // Template wraps a trogonerror.ErrorTemplate with the proto field | ||
| // specifications needed to derive per-instance metadata from a populated | ||
| // proto message. | ||
| type Template struct { | ||
| *trogonerror.ErrorTemplate | ||
| fields []protoFieldSpec | ||
| } | ||
|
|
||
| // NewErrorTemplateFromProto builds a Template 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 ...trogonerror.TemplateOption) *Template { | ||
| var zero T | ||
| desc := zero.ProtoReflect().Descriptor() | ||
|
|
||
| var domain, reason string | ||
| var derived []trogonerror.TemplateOption | ||
|
|
||
| if msgOpts, ok := proto.GetExtension(desc.Options(), errpb.E_Message).(*errpb.MessageOptions); ok && msgOpts != nil { | ||
| domain, reason, derived = templateOptionsFromProto(msgOpts.GetTemplate()) | ||
| } | ||
|
|
||
| fields := collectFieldSpecs(desc) | ||
| for _, spec := range fields { | ||
| if spec.hasFixedValue { | ||
| derived = append(derived, trogonerror.TemplateWithMetadataValue(spec.visibility, spec.key, spec.fixedValue)) | ||
| } | ||
| } | ||
|
|
||
| et := trogonerror.NewErrorTemplate(domain, reason, append(derived, options...)...) | ||
|
|
||
| return &Template{ErrorTemplate: et, fields: fields} | ||
| } | ||
|
|
||
| // 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 (t *Template) FromProto(m proto.Message, options ...trogonerror.ErrorOption) *trogonerror.TrogonError { | ||
| derived := make([]trogonerror.ErrorOption, 0, len(t.fields)) | ||
| reflected := m.ProtoReflect() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Reject messages that do not match the template descriptor. If a caller passes another message type, 🤖 Prompt for AI Agents |
||
|
|
||
| for _, spec := range t.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, trogonerror.WithMetadataValue(spec.visibility, spec.key, value)) | ||
| } | ||
|
|
||
| return t.NewError(append(derived, options...)...) | ||
| } | ||
|
|
||
| func templateOptionsFromProto(t *errpb.MessageOptions_Template) (domain, reason string, opts []trogonerror.TemplateOption) { | ||
| if t == nil { | ||
| return "", "", nil | ||
| } | ||
|
|
||
| domain = t.GetDomain() | ||
| reason = t.GetReason() | ||
|
|
||
| if m := t.GetMessage(); m != "" { | ||
| opts = append(opts, trogonerror.TemplateWithMessage(m)) | ||
| } | ||
| if c := t.GetCode(); c != errpb.Code_UNSPECIFIED { | ||
| opts = append(opts, trogonerror.TemplateWithCode(mapProtoCode(c))) | ||
| } | ||
| if v := t.GetVisibility(); v != errpb.Visibility_VISIBILITY_UNSPECIFIED { | ||
| opts = append(opts, trogonerror.TemplateWithVisibility(mapProtoVisibility(v))) | ||
| } | ||
| for _, link := range t.GetHelpLinks() { | ||
| opts = append(opts, trogonerror.TemplateWithHelpLink(link.GetDescription(), link.GetUrl())) | ||
| } | ||
| for _, entry := range t.GetMetadata() { | ||
| if entry.GetKey() == "" { | ||
| continue | ||
| } | ||
| opts = append(opts, trogonerror.TemplateWithMetadataValue(mapProtoVisibility(entry.GetVisibility()), entry.GetKey(), entry.GetValue())) | ||
| } | ||
|
|
||
| return domain, reason, opts | ||
| } | ||
|
|
||
| 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Preserve legitimate zero-valued field metadata. For a proto3 scalar without explicit presence, 🤖 Prompt for AI AgentsSource: Learnings |
||
| 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) trogonerror.Code { | ||
| switch c { | ||
| case errpb.Code_CANCELLED: | ||
| return trogonerror.CodeCancelled | ||
| case errpb.Code_UNKNOWN: | ||
| return trogonerror.CodeUnknown | ||
| case errpb.Code_INVALID_ARGUMENT: | ||
| return trogonerror.CodeInvalidArgument | ||
| case errpb.Code_DEADLINE_EXCEEDED: | ||
| return trogonerror.CodeDeadlineExceeded | ||
| case errpb.Code_NOT_FOUND: | ||
| return trogonerror.CodeNotFound | ||
| case errpb.Code_ALREADY_EXISTS: | ||
| return trogonerror.CodeAlreadyExists | ||
| case errpb.Code_PERMISSION_DENIED: | ||
| return trogonerror.CodePermissionDenied | ||
| case errpb.Code_RESOURCE_EXHAUSTED: | ||
| return trogonerror.CodeResourceExhausted | ||
| case errpb.Code_FAILED_PRECONDITION: | ||
| return trogonerror.CodeFailedPrecondition | ||
| case errpb.Code_ABORTED: | ||
| return trogonerror.CodeAborted | ||
| case errpb.Code_OUT_OF_RANGE: | ||
| return trogonerror.CodeOutOfRange | ||
| case errpb.Code_UNIMPLEMENTED: | ||
| return trogonerror.CodeUnimplemented | ||
| case errpb.Code_INTERNAL: | ||
| return trogonerror.CodeInternal | ||
| case errpb.Code_UNAVAILABLE: | ||
| return trogonerror.CodeUnavailable | ||
| case errpb.Code_DATA_LOSS: | ||
| return trogonerror.CodeDataLoss | ||
| case errpb.Code_UNAUTHENTICATED: | ||
| return trogonerror.CodeUnauthenticated | ||
| default: | ||
| return trogonerror.CodeUnknown | ||
| } | ||
| } | ||
|
|
||
| func mapProtoVisibility(v errpb.Visibility) trogonerror.Visibility { | ||
| switch v { | ||
| case errpb.Visibility_VISIBILITY_PUBLIC: | ||
| return trogonerror.VisibilityPublic | ||
| case errpb.Visibility_VISIBILITY_PRIVATE: | ||
| return trogonerror.VisibilityPrivate | ||
| default: | ||
| return trogonerror.VisibilityInternal | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Include new generated files in the regeneration check.
If a new proto creates a
.pb.gofile that was not committed,git diff --quietstill succeeds because the file is untracked. This job can then report that generated code is current when a binding is missing. Checkgit status --porcelainafter generation, or add generated paths to the index before checking the diff. (git-scm.com)🤖 Prompt for AI Agents