From 51c1dc30680dab603b96a65a9a59842c84db3b30 Mon Sep 17 00:00:00 2001 From: Patrik Date: Fri, 13 Nov 2026 13:47:21 +0100 Subject: [PATCH 1/3] feat: support protobuf error details --- v2/README.md | 32 ++++++++ v2/error.go | 23 +++++- v2/from.go | 7 +- v2/grpcerr/grpcerr.go | 43 +++++++--- v2/httperr/decode.go | 23 +++++- v2/httperr/httperr.go | 24 +++++- v2/options.go | 24 ++++++ v2/proto_details_test.go | 166 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 316 insertions(+), 26 deletions(-) create mode 100644 v2/proto_details_test.go diff --git a/v2/README.md b/v2/README.md index 2ed334e..4c1464b 100644 --- a/v2/README.md +++ b/v2/README.md @@ -116,3 +116,35 @@ version, a herodot v1 version, or both. It runs `scripts/release.sh`, which: Every herodot/v2 release bumps the root module. A herodot v1 release requires the root module to require the latest herodot/v2 release. Run `scripts/release.sh` without `--push` for a local dry run. + +### Protobuf error details + +Attach standard or service-specific protobuf messages with `ProtoDetail`: + +```go +err := serviceUnavailable.New( + herodot.ProtoDetail(&errdetails.RetryInfo{ + RetryDelay: durationpb.New(time.Second), + }), +) +``` + +`ProtoDetail` also works with `Annotate`. Messages are copied when attached; +`Error.ProtoDetails()` returns deep copies. Except for `DebugInfo`, these details +are public response data. Use `Cause` for internal diagnostics and `Violation` for invalid fields. Attached `DebugInfo` +is rendered only when debug output is enabled, just like cause-derived debug +information. `BadRequest` can also be attached directly. Only `ErrorInfo` and +Herodot's `CodeInfo` are reserved because they define the error's identity and +code; passing either or a nil message to `ProtoDetail` panics. Adding convenience +APIs for other detail types does not reserve those types. + +The HTTP and gRPC renderers include additional details alongside `ErrorInfo`. +HTTP uses protobuf JSON encoding, including `@type` and well-known types such as +durations. A request ID supplied by the transport takes precedence over an +attached `RequestInfo`. + +Decoders retain additional details whose protobuf types are registered in the +process. Import the generated Go package for service-specific details. Unknown +or malformed details are ignored. Decoded `DebugInfo` is retained but only +rendered when debug output is enabled. Renderers omit details that cannot be +encoded in their transport format. diff --git a/v2/error.go b/v2/error.go index 714468f..f3646c9 100644 --- a/v2/error.go +++ b/v2/error.go @@ -9,6 +9,8 @@ import ( "maps" "slices" "sort" + + "google.golang.org/protobuf/proto" ) // Error is an occurrence of a Definition. It is immutable: its only field @@ -20,10 +22,11 @@ type Error struct { } type errorData struct { - def Definition - metadata map[string]string - violations []FieldViolation - cause error + def Definition + metadata map[string]string + violations []FieldViolation + cause error + protoDetails []proto.Message } // FieldViolation describes a request field error. It maps to @@ -52,6 +55,18 @@ func (e Error) Metadata() map[string]string { return maps.Clone(e.d().metadata) // Violations returns a copy of the request field violations. func (e Error) Violations() []FieldViolation { return slices.Clone(e.d().violations) } +// ProtoDetails returns deep copies of the additional protobuf details. +func (e Error) ProtoDetails() []proto.Message { + if e.d().protoDetails == nil { + return nil + } + details := make([]proto.Message, len(e.d().protoDetails)) + for i, d := range e.d().protoDetails { + details[i] = proto.Clone(d) + } + return details +} + // Unwrap returns the cause. func (e Error) Unwrap() error { return e.d().cause } diff --git a/v2/from.go b/v2/from.go index e7f9912..179361b 100644 --- a/v2/from.go +++ b/v2/from.go @@ -125,9 +125,10 @@ func merge(e Error, path []*annotation) Error { } src := e.d() data := &errorData{ - def: src.def, - cause: src.cause, - violations: slices.Clone(src.violations), + def: src.def, + cause: src.cause, + violations: slices.Clone(src.violations), + protoDetails: e.ProtoDetails(), } if len(src.metadata) > 0 { data.metadata = make(map[string]string, len(src.metadata)) diff --git a/v2/grpcerr/grpcerr.go b/v2/grpcerr/grpcerr.go index d16488f..5b42123 100644 --- a/v2/grpcerr/grpcerr.go +++ b/v2/grpcerr/grpcerr.go @@ -25,8 +25,8 @@ import ( const RequestIDKey = "x-request-id" // ToStatus returns the status for herodot.From(err): the canonical code, -// ErrorInfo, CodeInfo for a refined code, RequestInfo and BadRequest, and -// DebugInfo only if debug is true. +// ErrorInfo, CodeInfo for a refined code, RequestInfo, BadRequest, additional +// protobuf details, and DebugInfo only if debug is true. // It returns nil if err is nil. func ToStatus(ctx context.Context, err error, debug bool) *status.Status { if err == nil { @@ -60,6 +60,17 @@ func ToStatus(ctx context.Context, err error, debug bool) *status.Status { } details = append(details, br) } + for _, detail := range e.ProtoDetails() { + if detail.ProtoReflect().Descriptor().FullName() == "google.rpc.DebugInfo" && !debug { + continue + } + if _, ok := detail.(*errdetails.RequestInfo); ok { + if md, ok := metadata.FromIncomingContext(ctx); ok && len(md.Get(RequestIDKey)) > 0 && md.Get(RequestIDKey)[0] != "" { + continue + } + } + details = append(details, protoadapt.MessageV1Of(detail)) + } if debug { if info, ok := e.DebugInfo(); ok { for i := range info.StackEntries { @@ -69,13 +80,15 @@ func ToStatus(ctx context.Context, err error, debug bool) *status.Status { } } - base := status.New(codes.Code(def.Code().Canonical()), validUTF8(e.Error())) - s, wErr := base.WithDetails(details...) - if wErr != nil { - // Preserve the status even if a detail cannot be serialized. - return base + result := status.New(codes.Code(def.Code().Canonical()), validUTF8(e.Error())) + for _, detail := range details { + next, err := result.WithDetails(detail) + if err != nil { + continue + } + result = next } - return s + return result } func validUTF8(s string) string { return strings.ToValidUTF8(s, "\uFFFD") } @@ -121,9 +134,9 @@ func FromStatus(s *status.Status) (herodot.Error, bool) { } var ( - info *errdetails.ErrorInfo - code = canonical - violations []herodot.Option + info *errdetails.ErrorInfo + code = canonical + details []herodot.Option ) for _, d := range s.Details() { switch d := d.(type) { @@ -138,7 +151,11 @@ func FromStatus(s *status.Status) (herodot.Error, bool) { } case *errdetails.BadRequest: for _, v := range d.GetFieldViolations() { - violations = append(violations, herodot.Violation(v.GetField(), v.GetDescription())) + details = append(details, herodot.Violation(v.GetField(), v.GetDescription())) + } + default: + if message, ok := d.(protoadapt.MessageV2); ok { + details = append(details, herodot.ProtoDetail(message)) } } } @@ -154,7 +171,7 @@ func FromStatus(s *status.Status) (herodot.Error, bool) { for k, v := range info.GetMetadata() { opts = append(opts, herodot.Metadata(k, v)) } - return def.New(append(opts, violations...)...), true + return def.New(append(opts, details...)...), true } // FromError decodes the status of an error returned by a gRPC call, also if diff --git a/v2/httperr/decode.go b/v2/httperr/decode.go index e045def..c310977 100644 --- a/v2/httperr/decode.go +++ b/v2/httperr/decode.go @@ -10,6 +10,9 @@ import ( "io" "net/http" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/anypb" + "github.com/ory/herodot/v2" ) @@ -73,8 +76,8 @@ func decode(body []byte, httpStatus int) (herodot.Error, bool) { } var ( - info *ErrorInfo - violations []herodot.Option + info *ErrorInfo + details []herodot.Option ) for _, raw := range b.Error.Details { var t struct { @@ -89,11 +92,23 @@ func decode(body []byte, httpStatus int) (herodot.Error, bool) { if info == nil && json.Unmarshal(raw, &d) == nil { info = &d } + case "type.googleapis.com/ory.herodot.v1.CodeInfo": case typePrefix + "BadRequest": var d BadRequest if json.Unmarshal(raw, &d) == nil { for _, v := range d.FieldViolations { - violations = append(violations, herodot.Violation(v.Field, v.Description)) + details = append(details, herodot.Violation(v.Field, v.Description)) + } + } + default: + var packed anypb.Any + if protojson.Unmarshal(raw, &packed) == nil { + if message, err := packed.UnmarshalNew(); err == nil { + switch message.ProtoReflect().Descriptor().FullName() { + case "google.rpc.ErrorInfo", "ory.herodot.v1.CodeInfo": + default: + details = append(details, herodot.ProtoDetail(message)) + } } } } @@ -110,7 +125,7 @@ func decode(body []byte, httpStatus int) (herodot.Error, bool) { for k, v := range info.Metadata { opts = append(opts, herodot.Metadata(k, v)) } - return def.New(append(opts, violations...)...), true + return def.New(append(opts, details...)...), true } // ResponseError is the cause of a decoded Error: the received response. diff --git a/v2/httperr/httperr.go b/v2/httperr/httperr.go index 2421534..d8ea376 100644 --- a/v2/httperr/httperr.go +++ b/v2/httperr/httperr.go @@ -8,6 +8,10 @@ import ( "encoding/json" "net/http" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/anypb" + "github.com/ory/herodot/v2" ) @@ -126,6 +130,23 @@ func NewBody(e herodot.Error, o Options) Body { } details = append(details, br) } + for _, detail := range e.ProtoDetails() { + if detail.ProtoReflect().Descriptor().FullName() == "google.rpc.DebugInfo" && !o.Debug { + continue + } + if _, ok := detail.(*errdetails.RequestInfo); ok && o.RequestID != "" { + continue + } + packed, err := anypb.New(detail) + if err != nil { + continue + } + raw, err := protojson.Marshal(packed) + if err != nil { + continue + } + details = append(details, json.RawMessage(raw)) + } if o.Debug { if info, ok := e.DebugInfo(); ok { details = append(details, DebugInfo{Type: typePrefix + "DebugInfo", StackEntries: info.StackEntries, Detail: info.Detail}) @@ -142,8 +163,7 @@ func NewBody(e herodot.Error, o Options) Body { func renderAIP(w http.ResponseWriter, _ *http.Request, e herodot.Error, o Options) { b, err := json.Marshal(NewBody(e, o)) if err != nil { - // Only strings and slices of strings are encoded, so this can't - // happen. + // Details have already been encoded as valid JSON. panic(err) } w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/v2/options.go b/v2/options.go index b93b523..97db037 100644 --- a/v2/options.go +++ b/v2/options.go @@ -3,6 +3,8 @@ package herodot +import "google.golang.org/protobuf/proto" + // Option configures an Error in Definition.New. type Option interface { apply(*errorData) @@ -52,3 +54,25 @@ func Metadata(key, value string) Detail { func Violation(field, description string) Detail { return violationDetail{Field: field, Description: description} } + +type protoDetail struct{ message proto.Message } + +func (p protoDetail) apply(d *errorData) { + d.protoDetails = append(d.protoDetails, proto.Clone(p.message)) +} +func (protoDetail) detail() {} + +// ProtoDetail adds a protobuf detail and copies its message. It panics for nil +// messages or the identity and code types managed by Herodot: ErrorInfo and CodeInfo. +// DebugInfo is rendered only when debug output is enabled. All other details +// are public response data and must not contain internal diagnostics. +func ProtoDetail(message proto.Message) Detail { + if message == nil || !message.ProtoReflect().IsValid() { + panic("herodot: nil protobuf detail") + } + switch message.ProtoReflect().Descriptor().FullName() { + case "google.rpc.ErrorInfo", "ory.herodot.v1.CodeInfo": + panic("herodot: protobuf detail type is managed by Herodot") + } + return protoDetail{message: proto.Clone(message)} +} diff --git a/v2/proto_details_test.go b/v2/proto_details_test.go new file mode 100644 index 0000000..a7a5511 --- /dev/null +++ b/v2/proto_details_test.go @@ -0,0 +1,166 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package herodot_test + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "google.golang.org/grpc/metadata" + + "github.com/ory/herodot/herodotpb" + + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/ory/herodot/v2" + "github.com/ory/herodot/v2/grpcerr" + "github.com/ory/herodot/v2/httperr" +) + +func TestProtoDetails(t *testing.T) { + t.Parallel() + retry := &errdetails.RetryInfo{RetryDelay: durationpb.New(time.Second)} + option := herodot.ProtoDetail(retry) + retry.RetryDelay.Seconds = 99 + original := herodot.Define("keto.ory.com", "UNAVAILABLE", herodot.CodeUnavailable, "Service unavailable").New(option) + got := original.ProtoDetails() + require.Len(t, got, 1) + require.EqualValues(t, 1, got[0].(*errdetails.RetryInfo).RetryDelay.Seconds) + got[0].(*errdetails.RetryInfo).RetryDelay.Seconds = 42 + resource := &errdetails.ResourceInfo{ResourceType: "keto.ory.com/Namespace", ResourceName: "namespaces/example"} + e := herodot.From(herodot.Annotate(original, herodot.ProtoDetail(resource))) + require.Len(t, original.ProtoDetails(), 1) + require.Len(t, e.ProtoDetails(), 2) + for _, transport := range []string{"grpc", "http"} { + t.Run(transport, func(t *testing.T) { + t.Parallel() + var decoded herodot.Error + var ok bool + if transport == "grpc" { + decoded, ok = grpcerr.FromStatus(grpcerr.ToStatus(t.Context(), e, false)) + } else { + body, err := json.Marshal(httperr.NewBody(e, httperr.Options{})) + require.NoError(t, err) + require.Contains(t, string(body), `"retryDelay":"1s"`) + decoded, ok = httperr.DecodeBody(body) + } + require.True(t, ok) + require.Len(t, decoded.ProtoDetails(), 2) + for i, detail := range e.ProtoDetails() { + require.True(t, proto.Equal(detail, decoded.ProtoDetails()[i])) + } + }) + } +} + +func TestProtoDetailValidation(t *testing.T) { + t.Parallel() + for _, message := range []proto.Message{nil, (*errdetails.RetryInfo)(nil), &errdetails.ErrorInfo{}, &herodotpb.CodeInfo{}} { + t.Run(fmt.Sprintf("%T", message), func(t *testing.T) { + t.Parallel() + require.Panics(t, func() { herodot.ProtoDetail(message) }) + }) + } +} + +func TestProtoDetailRequestID(t *testing.T) { + t.Parallel() + e := herodot.Internal.New(herodot.ProtoDetail(&errdetails.RequestInfo{RequestId: "original"})) + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-request-id", "current")) + s := grpcerr.ToStatus(ctx, e, false) + require.Len(t, s.Details(), 2) + require.Equal(t, "current", s.Details()[1].(*errdetails.RequestInfo).RequestId) + body, err := json.Marshal(httperr.NewBody(e, httperr.Options{RequestID: "current"})) + require.NoError(t, err) + decoded, ok := httperr.DecodeBody(body) + require.True(t, ok) + require.Len(t, decoded.ProtoDetails(), 1) + require.Equal(t, "current", decoded.ProtoDetails()[0].(*errdetails.RequestInfo).RequestId) +} + +func TestProtoDetailUnknownHTTP(t *testing.T) { + t.Parallel() + for _, detail := range []string{ + `{"@type":"type.googleapis.com/example.Unknown","value":1}`, + `{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"invalid"}`, + } { + body := `{"error":{"code":500,"status":"INTERNAL","message":"Internal server error","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","domain":"ory.com","reason":"INTERNAL"},` + detail + `]}}` + decoded, ok := httperr.DecodeBody([]byte(body)) + require.True(t, ok) + require.Empty(t, decoded.ProtoDetails()) + } +} + +func TestProtoDetailMalformed(t *testing.T) { + t.Parallel() + e := herodot.Internal.New(herodot.ProtoDetail(&errdetails.ResourceInfo{ResourceName: string([]byte{0xff})})) + s := grpcerr.ToStatus(t.Context(), e, false) + require.Len(t, s.Details(), 1) + require.IsType(t, &errdetails.ErrorInfo{}, s.Details()[0]) + body, err := json.Marshal(httperr.NewBody(e, httperr.Options{})) + require.NoError(t, err) + decoded, ok := httperr.DecodeBody(body) + require.True(t, ok) + require.Empty(t, decoded.ProtoDetails()) +} + +func TestProtoDetailDebugRendering(t *testing.T) { + t.Parallel() + debug := &errdetails.DebugInfo{Detail: "internal diagnostics", StackEntries: []string{"frame"}} + e := herodot.Internal.New(herodot.ProtoDetail(debug)) + for _, transport := range []string{"grpc", "http"} { + t.Run(transport, func(t *testing.T) { + render := func(e herodot.Error, enabled bool) herodot.Error { + var decoded herodot.Error + var ok bool + if transport == "grpc" { + decoded, ok = grpcerr.FromStatus(grpcerr.ToStatus(t.Context(), e, enabled)) + } else { + body, err := json.Marshal(httperr.NewBody(e, httperr.Options{Debug: enabled})) + require.NoError(t, err) + decoded, ok = httperr.DecodeBody(body) + } + require.True(t, ok) + return decoded + } + require.Empty(t, render(e, false).ProtoDetails()) + decoded := render(e, true) + require.Len(t, decoded.ProtoDetails(), 1) + require.True(t, proto.Equal(debug, decoded.ProtoDetails()[0])) + // Forwarding a decoded error must apply the local render policy. + require.Empty(t, render(decoded, false).ProtoDetails()) + }) + } +} + +func TestProtoDetailBadRequest(t *testing.T) { + t.Parallel() + detail := &errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{{Field: "name", Description: "required"}}} + e := herodot.Internal.New(herodot.ProtoDetail(detail)) + require.True(t, proto.Equal(detail, e.ProtoDetails()[0])) + grpcDecoded, ok := grpcerr.FromStatus(grpcerr.ToStatus(t.Context(), e, false)) + require.True(t, ok) + require.Equal(t, []herodot.FieldViolation{{Field: "name", Description: "required"}}, grpcDecoded.Violations()) + body, err := json.Marshal(httperr.NewBody(e, httperr.Options{})) + require.NoError(t, err) + httpDecoded, ok := httperr.DecodeBody(body) + require.True(t, ok) + require.Equal(t, grpcDecoded.Violations(), httpDecoded.Violations()) +} + +func TestProtoDetailDebugAlternateTypeURL(t *testing.T) { + t.Parallel() + body := `{"error":{"code":500,"status":"INTERNAL","message":"Internal server error","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","domain":"ory.com","reason":"INTERNAL"},{"@type":"example.com/google.rpc.DebugInfo","detail":"remote diagnostics"}]}}` + decoded, ok := httperr.DecodeBody([]byte(body)) + require.True(t, ok) + require.Len(t, decoded.ProtoDetails(), 1) + rendered, err := json.Marshal(httperr.NewBody(decoded, httperr.Options{})) + require.NoError(t, err) + require.NotContains(t, string(rendered), "remote diagnostics") +} From 359ae0166c38c38cd597dccb2be580868500cc9b Mon Sep 17 00:00:00 2001 From: Patrik Date: Fri, 13 Nov 2026 14:18:14 +0100 Subject: [PATCH 2/3] style: format protobuf details documentation --- v2/README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/v2/README.md b/v2/README.md index 4c1464b..f6dc29f 100644 --- a/v2/README.md +++ b/v2/README.md @@ -130,13 +130,14 @@ err := serviceUnavailable.New( ``` `ProtoDetail` also works with `Annotate`. Messages are copied when attached; -`Error.ProtoDetails()` returns deep copies. Except for `DebugInfo`, these details -are public response data. Use `Cause` for internal diagnostics and `Violation` for invalid fields. Attached `DebugInfo` -is rendered only when debug output is enabled, just like cause-derived debug -information. `BadRequest` can also be attached directly. Only `ErrorInfo` and -Herodot's `CodeInfo` are reserved because they define the error's identity and -code; passing either or a nil message to `ProtoDetail` panics. Adding convenience -APIs for other detail types does not reserve those types. +`Error.ProtoDetails()` returns deep copies. Except for `DebugInfo`, these +details are public response data. Use `Cause` for internal diagnostics and +`Violation` for invalid fields. Attached `DebugInfo` is rendered only when debug +output is enabled, just like cause-derived debug information. `BadRequest` can +also be attached directly. Only `ErrorInfo` and Herodot's `CodeInfo` are +reserved because they define the error's identity and code; passing either or a +nil message to `ProtoDetail` panics. Adding convenience APIs for other detail +types does not reserve those types. The HTTP and gRPC renderers include additional details alongside `ErrorInfo`. HTTP uses protobuf JSON encoding, including `@type` and well-known types such as From fbb10595b74e661d9ccd86b79672f92f2a82144f Mon Sep 17 00:00:00 2001 From: Patrik Date: Fri, 13 Nov 2026 19:04:01 +0100 Subject: [PATCH 3/3] fix: preserve and merge protobuf validation details --- v2/README.md | 10 +++ v2/error.go | 26 +++--- v2/from.go | 9 +- v2/grpcerr/grpcerr.go | 25 +++--- v2/grpcerr/grpcerr_test.go | 2 +- v2/herodot_test.go | 13 +-- v2/httperr/decode.go | 7 -- v2/httperr/decode_test.go | 5 +- v2/httperr/httperr.go | 23 +----- v2/options.go | 25 +++--- v2/proto_details_test.go | 165 ++++++++++++++++++++++++++++++++++++- 11 files changed, 228 insertions(+), 82 deletions(-) diff --git a/v2/README.md b/v2/README.md index f6dc29f..d759abd 100644 --- a/v2/README.md +++ b/v2/README.md @@ -139,6 +139,16 @@ reserved because they define the error's identity and code; passing either or a nil message to `ProtoDetail` panics. Adding convenience APIs for other detail types does not reserve those types. +`Violation(field, description)` is a convenience for attaching a `BadRequest` +with one field violation. Full `BadRequest` messages are retained when decoding +and forwarding, including reasons and localized messages. Read the complete +merged `BadRequest` through `ProtoDetails()`. All `BadRequest` messages merge +into one detail, including messages attached through `ProtoDetail` or decoded +from either transport. Violations append in attachment order without +deduplication, and protobuf unknown fields are preserved during merging. +`ProtoDetails()` returns the merged `BadRequest` first, followed by other +details in attachment order. + The HTTP and gRPC renderers include additional details alongside `ErrorInfo`. HTTP uses protobuf JSON encoding, including `@type` and well-known types such as durations. A request ID supplied by the transport takes precedence over an diff --git a/v2/error.go b/v2/error.go index f3646c9..ef17675 100644 --- a/v2/error.go +++ b/v2/error.go @@ -10,6 +10,7 @@ import ( "slices" "sort" + "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/protobuf/proto" ) @@ -24,15 +25,11 @@ type Error struct { type errorData struct { def Definition metadata map[string]string - violations []FieldViolation cause error + badRequest *errdetails.BadRequest protoDetails []proto.Message } -// FieldViolation describes a request field error. It maps to -// google.rpc.BadRequest.FieldViolation. -type FieldViolation struct{ Field, Description string } - var zeroData = &errorData{def: Internal} func (e Error) d() *errorData { @@ -52,17 +49,16 @@ func (e Error) Error() string { return e.d().def.message } // none. func (e Error) Metadata() map[string]string { return maps.Clone(e.d().metadata) } -// Violations returns a copy of the request field violations. -func (e Error) Violations() []FieldViolation { return slices.Clone(e.d().violations) } - // ProtoDetails returns deep copies of the additional protobuf details. +// The merged BadRequest, if present, comes first, followed by other details in +// attachment order. func (e Error) ProtoDetails() []proto.Message { - if e.d().protoDetails == nil { - return nil + var details []proto.Message + if e.d().badRequest != nil { + details = append(details, proto.Clone(e.d().badRequest)) } - details := make([]proto.Message, len(e.d().protoDetails)) - for i, d := range e.d().protoDetails { - details[i] = proto.Clone(d) + for _, d := range e.d().protoDetails { + details = append(details, proto.Clone(d)) } return details } @@ -99,8 +95,8 @@ func (e Error) Format(s fmt.State, verb rune) { for _, k := range keys { _, _ = fmt.Fprintf(s, "\nmetadata %s=%s", k, d.metadata[k]) } - for _, v := range d.violations { - _, _ = fmt.Fprintf(s, "\nviolation %s: %s", v.Field, v.Description) + for _, v := range d.badRequest.GetFieldViolations() { + _, _ = fmt.Fprintf(s, "\nviolation %s: %s", v.GetField(), v.GetDescription()) } if d.cause != nil { _, _ = fmt.Fprintf(s, "\ncause: %+v", d.cause) diff --git a/v2/from.go b/v2/from.go index 179361b..0fd8af1 100644 --- a/v2/from.go +++ b/v2/from.go @@ -125,10 +125,11 @@ func merge(e Error, path []*annotation) Error { } src := e.d() data := &errorData{ - def: src.def, - cause: src.cause, - violations: slices.Clone(src.violations), - protoDetails: e.ProtoDetails(), + def: src.def, + cause: src.cause, + } + for _, detail := range e.ProtoDetails() { + protoDetail{message: detail}.apply(data) } if len(src.metadata) > 0 { data.metadata = make(map[string]string, len(src.metadata)) diff --git a/v2/grpcerr/grpcerr.go b/v2/grpcerr/grpcerr.go index 5b42123..f2c8756 100644 --- a/v2/grpcerr/grpcerr.go +++ b/v2/grpcerr/grpcerr.go @@ -53,22 +53,23 @@ func ToStatus(ctx context.Context, err error, debug bool) *status.Status { details = append(details, &errdetails.RequestInfo{RequestId: validUTF8(ids[0])}) } } - if vs := e.Violations(); len(vs) > 0 { - br := &errdetails.BadRequest{FieldViolations: make([]*errdetails.BadRequest_FieldViolation, len(vs))} - for i, v := range vs { - br.FieldViolations[i] = &errdetails.BadRequest_FieldViolation{Field: validUTF8(v.Field), Description: validUTF8(v.Description)} - } - details = append(details, br) - } for _, detail := range e.ProtoDetails() { if detail.ProtoReflect().Descriptor().FullName() == "google.rpc.DebugInfo" && !debug { continue } - if _, ok := detail.(*errdetails.RequestInfo); ok { + if detail.ProtoReflect().Descriptor().FullName() == "google.rpc.RequestInfo" { if md, ok := metadata.FromIncomingContext(ctx); ok && len(md.Get(RequestIDKey)) > 0 && md.Get(RequestIDKey)[0] != "" { continue } } + if br, ok := detail.(*errdetails.BadRequest); ok { + for _, v := range br.FieldViolations { + if v != nil { + v.Field = validUTF8(v.Field) + v.Description = validUTF8(v.Description) + } + } + } details = append(details, protoadapt.MessageV1Of(detail)) } if debug { @@ -149,13 +150,9 @@ func FromStatus(s *status.Status) (herodot.Error, bool) { if c := herodot.Code(d.GetCode()); c.Canonical() == canonical { code = c } - case *errdetails.BadRequest: - for _, v := range d.GetFieldViolations() { - details = append(details, herodot.Violation(v.GetField(), v.GetDescription())) - } default: - if message, ok := d.(protoadapt.MessageV2); ok { - details = append(details, herodot.ProtoDetail(message)) + if message, ok := d.(protoadapt.MessageV1); ok { + details = append(details, herodot.ProtoDetail(protoadapt.MessageV2Of(message))) } } } diff --git a/v2/grpcerr/grpcerr_test.go b/v2/grpcerr/grpcerr_test.go index 559c667..1ad879a 100644 --- a/v2/grpcerr/grpcerr_test.go +++ b/v2/grpcerr/grpcerr_test.go @@ -80,7 +80,7 @@ func TestFromStatus(t *testing.T) { assert.Equal(t, herodot.CodeExpired, received.Definition().Code(), "the refinement survives gRPC") assert.Equal(t, "The flow has expired. Start a new flow.", received.Error(), "the received message is kept") assert.Equal(t, map[string]string{"flowId": "1234"}, received.Metadata()) - assert.Equal(t, []herodot.FieldViolation{{Field: "email", Description: "is required"}}, received.Violations()) + assert.True(t, proto.Equal(&errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{{Field: "email", Description: "is required"}}}, received.ProtoDetails()[0])) assert.Equal(t, s.Err().Error(), received.Unwrap().Error()) forwarded := grpcerr.ToStatus(context.Background(), fmt.Errorf("forwarding: %w", received), false) diff --git a/v2/herodot_test.go b/v2/herodot_test.go index 4d06835..338d3f9 100644 --- a/v2/herodot_test.go +++ b/v2/herodot_test.go @@ -10,6 +10,9 @@ import ( "strings" "testing" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/protobuf/proto" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -67,21 +70,21 @@ func TestNew(t *testing.T) { assert.Equal(t, identityNotFound, e.Definition()) assert.Equal(t, "The identity could not be found.", e.Error()) assert.Equal(t, map[string]string{"identityId": "1234"}, e.Metadata()) - assert.Equal(t, []herodot.FieldViolation{{Field: "id", Description: "must exist"}}, e.Violations()) + assert.True(t, proto.Equal(&errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{{Field: "id", Description: "must exist"}}}, e.ProtoDetails()[0])) assert.Same(t, cause, e.Unwrap()) assert.ErrorIs(t, e, cause) t.Run("case=copies are immutable", func(t *testing.T) { e.Metadata()["identityId"] = "changed" - e.Violations()[0].Field = "changed" + e.ProtoDetails()[0].(*errdetails.BadRequest).FieldViolations[0].Field = "changed" assert.Equal(t, "1234", e.Metadata()["identityId"]) - assert.Equal(t, "id", e.Violations()[0].Field) + assert.Equal(t, "id", e.ProtoDetails()[0].(*errdetails.BadRequest).FieldViolations[0].Field) }) t.Run("case=no details", func(t *testing.T) { e := flowExpired.New() assert.Nil(t, e.Metadata()) - assert.Nil(t, e.Violations()) + assert.Nil(t, e.ProtoDetails()) assert.Nil(t, e.Unwrap()) }) } @@ -229,7 +232,7 @@ func TestFrom(t *testing.T) { assert.Equal(t, flowExpired, got.Definition()) assert.Equal(t, e.Error(), got.Error()) assert.Equal(t, map[string]string{"flowId": "outer", "keep": "inner", "returnTo": "/x"}, got.Metadata()) - assert.Equal(t, []herodot.FieldViolation{{"a", "1"}, {"b", "2"}, {"c", "3"}}, got.Violations()) + assert.True(t, proto.Equal(&errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{{Field: "a", Description: "1"}, {Field: "b", Description: "2"}, {Field: "c", Description: "3"}}}, got.ProtoDetails()[0])) assert.Same(t, below, got.Unwrap()) assert.Equal(t, map[string]string{"flowId": "1", "keep": "inner"}, e.Metadata(), "the original is unchanged") diff --git a/v2/httperr/decode.go b/v2/httperr/decode.go index c310977..0c2897b 100644 --- a/v2/httperr/decode.go +++ b/v2/httperr/decode.go @@ -93,13 +93,6 @@ func decode(body []byte, httpStatus int) (herodot.Error, bool) { info = &d } case "type.googleapis.com/ory.herodot.v1.CodeInfo": - case typePrefix + "BadRequest": - var d BadRequest - if json.Unmarshal(raw, &d) == nil { - for _, v := range d.FieldViolations { - details = append(details, herodot.Violation(v.Field, v.Description)) - } - } default: var packed anypb.Any if protojson.Unmarshal(raw, &packed) == nil { diff --git a/v2/httperr/decode_test.go b/v2/httperr/decode_test.go index d83791e..61fa76b 100644 --- a/v2/httperr/decode_test.go +++ b/v2/httperr/decode_test.go @@ -11,6 +11,9 @@ import ( "strings" "testing" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/protobuf/proto" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -43,7 +46,7 @@ func TestDecode(t *testing.T) { assert.Equal(t, herodot.CodeExpired, received.Definition().Code(), "the refinement survives HTTP") assert.Equal(t, "The flow has expired. Start a new flow.", received.Error(), "the received message is kept") assert.Equal(t, map[string]string{"flowId": "1234"}, received.Metadata()) - assert.Equal(t, []herodot.FieldViolation{{Field: "email", Description: "is required"}}, received.Violations()) + assert.True(t, proto.Equal(&errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{{Field: "email", Description: "is required"}}}, received.ProtoDetails()[0])) var re *httperr.ResponseError require.ErrorAs(t, received, &re) diff --git a/v2/httperr/httperr.go b/v2/httperr/httperr.go index d8ea376..93a2e59 100644 --- a/v2/httperr/httperr.go +++ b/v2/httperr/httperr.go @@ -8,7 +8,6 @@ import ( "encoding/json" "net/http" - "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/anypb" @@ -89,19 +88,6 @@ type RequestInfo struct { RequestID string `json:"requestId"` } -// BadRequest is the JSON representation of google.rpc.BadRequest. -type BadRequest struct { - Type string `json:"@type"` - FieldViolations []FieldViolation `json:"fieldViolations"` -} - -// FieldViolation is the JSON representation of -// google.rpc.BadRequest.FieldViolation. -type FieldViolation struct { - Field string `json:"field"` - Description string `json:"description"` -} - // DebugInfo is the JSON representation of google.rpc.DebugInfo. type DebugInfo struct { Type string `json:"@type"` @@ -123,18 +109,11 @@ func NewBody(e herodot.Error, o Options) Body { if o.RequestID != "" { details = append(details, RequestInfo{Type: typePrefix + "RequestInfo", RequestID: o.RequestID}) } - if vs := e.Violations(); len(vs) > 0 { - br := BadRequest{Type: typePrefix + "BadRequest", FieldViolations: make([]FieldViolation, len(vs))} - for i, v := range vs { - br.FieldViolations[i] = FieldViolation(v) - } - details = append(details, br) - } for _, detail := range e.ProtoDetails() { if detail.ProtoReflect().Descriptor().FullName() == "google.rpc.DebugInfo" && !o.Debug { continue } - if _, ok := detail.(*errdetails.RequestInfo); ok && o.RequestID != "" { + if detail.ProtoReflect().Descriptor().FullName() == "google.rpc.RequestInfo" && o.RequestID != "" { continue } packed, err := anypb.New(detail) diff --git a/v2/options.go b/v2/options.go index 97db037..dcafb24 100644 --- a/v2/options.go +++ b/v2/options.go @@ -3,7 +3,10 @@ package herodot -import "google.golang.org/protobuf/proto" +import ( + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/protobuf/proto" +) // Option configures an Error in Definition.New. type Option interface { @@ -31,13 +34,6 @@ func (m metadataDetail) apply(d *errorData) { } func (metadataDetail) detail() {} -type violationDetail FieldViolation - -func (v violationDetail) apply(d *errorData) { - d.violations = append(d.violations, FieldViolation(v)) -} -func (violationDetail) detail() {} - // Cause sets the wrapped error. It is internal: transports expose it only as // debug output. func Cause(err error) Option { @@ -50,20 +46,29 @@ func Metadata(key, value string) Detail { return metadataDetail{key: key, value: value} } -// Violation adds a request field violation. +// Violation appends a field violation to the error's BadRequest detail. func Violation(field, description string) Detail { - return violationDetail{Field: field, Description: description} + return ProtoDetail(&errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{{Field: field, Description: description}}}) } type protoDetail struct{ message proto.Message } func (p protoDetail) apply(d *errorData) { + if p.message.ProtoReflect().Descriptor().FullName() == "google.rpc.BadRequest" { + if d.badRequest == nil { + d.badRequest = &errdetails.BadRequest{} + } + proto.Merge(d.badRequest, p.message) + return + } d.protoDetails = append(d.protoDetails, proto.Clone(p.message)) } func (protoDetail) detail() {} // ProtoDetail adds a protobuf detail and copies its message. It panics for nil // messages or the identity and code types managed by Herodot: ErrorInfo and CodeInfo. +// BadRequest messages merge into one detail, appending complete violations in +// attachment order without deduplication and preserving unknown fields. // DebugInfo is rendered only when debug output is enabled. All other details // are public response data and must not contain internal diagnostics. func ProtoDetail(message proto.Message) Detail { diff --git a/v2/proto_details_test.go b/v2/proto_details_test.go index a7a5511..ad4828a 100644 --- a/v2/proto_details_test.go +++ b/v2/proto_details_test.go @@ -16,6 +16,12 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/protoadapt" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" "google.golang.org/protobuf/types/known/durationpb" "github.com/ory/herodot/v2" @@ -141,17 +147,39 @@ func TestProtoDetailDebugRendering(t *testing.T) { func TestProtoDetailBadRequest(t *testing.T) { t.Parallel() - detail := &errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{{Field: "name", Description: "required"}}} + detail := &errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{{ + Field: "name", Description: "required", Reason: "MISSING_NAME", + LocalizedMessage: &errdetails.LocalizedMessage{Locale: "de", Message: "Name erforderlich"}, + }}} e := herodot.Internal.New(herodot.ProtoDetail(detail)) require.True(t, proto.Equal(detail, e.ProtoDetails()[0])) grpcDecoded, ok := grpcerr.FromStatus(grpcerr.ToStatus(t.Context(), e, false)) require.True(t, ok) - require.Equal(t, []herodot.FieldViolation{{Field: "name", Description: "required"}}, grpcDecoded.Violations()) + require.True(t, proto.Equal(detail, grpcDecoded.ProtoDetails()[0])) body, err := json.Marshal(httperr.NewBody(e, httperr.Options{})) require.NoError(t, err) httpDecoded, ok := httperr.DecodeBody(body) require.True(t, ok) - require.Equal(t, grpcDecoded.Violations(), httpDecoded.Violations()) + require.True(t, proto.Equal(grpcDecoded.ProtoDetails()[0], httpDecoded.ProtoDetails()[0])) + for _, decoded := range []herodot.Error{grpcDecoded, httpDecoded} { + require.Len(t, decoded.ProtoDetails(), 1) + require.True(t, proto.Equal(detail, decoded.ProtoDetails()[0])) + require.Len(t, grpcerr.ToStatus(t.Context(), decoded, false).Details(), 2) + forwarded, err := json.Marshal(httperr.NewBody(decoded, httperr.Options{})) + require.NoError(t, err) + roundtrip, ok := httperr.DecodeBody(forwarded) + require.True(t, ok) + require.Len(t, roundtrip.ProtoDetails(), 1) + require.True(t, proto.Equal(detail, roundtrip.ProtoDetails()[0])) + } + // Convenience violations and full messages share one storage path. + annotated := herodot.From(herodot.Annotate(e, herodot.Violation("email", "required"))) + require.Len(t, e.ProtoDetails()[0].(*errdetails.BadRequest).FieldViolations, 1) + require.Len(t, annotated.ProtoDetails()[0].(*errdetails.BadRequest).FieldViolations, 2) + require.Len(t, annotated.ProtoDetails(), 1) + dynamic := dynamicpb.NewMessage(detail.ProtoReflect().Descriptor()) + proto.Merge(dynamic, detail) + require.True(t, proto.Equal(e.ProtoDetails()[0], herodot.Internal.New(herodot.ProtoDetail(dynamic)).ProtoDetails()[0])) } func TestProtoDetailDebugAlternateTypeURL(t *testing.T) { @@ -164,3 +192,134 @@ func TestProtoDetailDebugAlternateTypeURL(t *testing.T) { require.NoError(t, err) require.NotContains(t, string(rendered), "remote diagnostics") } + +func TestProtoDetailDynamicRequestID(t *testing.T) { + t.Parallel() + detail := dynamicpb.NewMessage((&errdetails.RequestInfo{}).ProtoReflect().Descriptor()) + detail.Set(detail.Descriptor().Fields().ByName("request_id"), protoreflect.ValueOfString("attached")) + e := herodot.Internal.New(herodot.ProtoDetail(detail)) + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-request-id", "transport")) + s := grpcerr.ToStatus(ctx, e, false) + require.Len(t, s.Details(), 2) + require.Equal(t, "transport", s.Details()[1].(*errdetails.RequestInfo).RequestId) + body, err := json.Marshal(httperr.NewBody(e, httperr.Options{RequestID: "transport"})) + require.NoError(t, err) + decoded, ok := httperr.DecodeBody(body) + require.True(t, ok) + require.Len(t, decoded.ProtoDetails(), 1) + require.Equal(t, "transport", decoded.ProtoDetails()[0].(*errdetails.RequestInfo).RequestId) +} + +// Model a registered message implementing only the protobuf v2 API. +// Override reflection's constructors so cloning and unmarshalling retain that API. +type v2OnlyDetail struct{ reflection v2OnlyReflection } + +func (m *v2OnlyDetail) ProtoReflect() protoreflect.Message { return m.reflection } + +type v2OnlyReflection struct { + protoreflect.Message + owner *v2OnlyDetail + kind v2OnlyType +} + +func (m v2OnlyReflection) Interface() protoreflect.ProtoMessage { return m.owner } +func (m v2OnlyReflection) Type() protoreflect.MessageType { return m.kind } +func (m v2OnlyReflection) New() protoreflect.Message { return m.kind.New() } + +type v2OnlyType struct { + descriptor protoreflect.MessageDescriptor +} + +func (t v2OnlyType) Descriptor() protoreflect.MessageDescriptor { return t.descriptor } +func (t v2OnlyType) New() protoreflect.Message { + m := &v2OnlyDetail{} + m.reflection = v2OnlyReflection{Message: dynamicpb.NewMessage(t.descriptor), owner: m, kind: t} + return m.reflection +} +func (t v2OnlyType) Zero() protoreflect.Message { return t.New() } + +func TestProtoDetailV2OnlyGRPC(t *testing.T) { + file, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("herodot_test_v2_only.proto"), Package: proto.String("herodot.test"), Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{{Name: proto.String("V2OnlyDetail"), Field: []*descriptorpb.FieldDescriptorProto{{ + Name: proto.String("value"), Number: proto.Int32(1), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + }}}}, + }, nil) + require.NoError(t, err) + kind := v2OnlyType{descriptor: file.Messages().Get(0)} + // Permit repeated test runs in the same process. + if _, err := protoregistry.GlobalTypes.FindMessageByName(kind.Descriptor().FullName()); err != nil { + require.NoError(t, protoregistry.GlobalTypes.RegisterMessage(kind)) + } + detail := kind.New() + detail.Set(kind.Descriptor().Fields().ByName("value"), protoreflect.ValueOfString("retained")) + e := herodot.Internal.New(herodot.ProtoDetail(detail.Interface())) + s := grpcerr.ToStatus(t.Context(), e, false) + require.Len(t, s.Details(), 2) + _, implementsV2 := s.Details()[1].(protoadapt.MessageV2) + require.False(t, implementsV2, "gRPC returns a v1 wrapper for v2-only messages") + decoded, ok := grpcerr.FromStatus(s) + require.True(t, ok) + require.Len(t, decoded.ProtoDetails(), 1) + require.True(t, proto.Equal(detail.Interface(), decoded.ProtoDetails()[0])) +} + +func TestBadRequestMerge(t *testing.T) { + t.Parallel() + first := &errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{{ + Field: "name", Description: "required", Reason: "MISSING", + LocalizedMessage: &errdetails.LocalizedMessage{Locale: "de", Message: "Name erforderlich"}, + }}} + // Unknown varint fields at message and violation level must survive merging. + first.ProtoReflect().SetUnknown([]byte{0x50, 1}) + first.FieldViolations[0].ProtoReflect().SetUnknown([]byte{0x50, 2}) + second := dynamicpb.NewMessage(first.ProtoReflect().Descriptor()) + proto.Merge(second, first) + second.SetUnknown([]byte{0x58, 3}) + original := herodot.Internal.New(herodot.ProtoDetail(first)) + merged := herodot.From(herodot.Annotate(original, + herodot.Violation("email", "required"), herodot.ProtoDetail(second), + herodot.ProtoDetail(&errdetails.RetryInfo{}), + )) + require.Len(t, original.ProtoDetails()[0].(*errdetails.BadRequest).FieldViolations, 1) + require.Equal(t, "email", merged.ProtoDetails()[0].(*errdetails.BadRequest).FieldViolations[1].Field) + require.Equal(t, "required", merged.ProtoDetails()[0].(*errdetails.BadRequest).FieldViolations[1].Description) + details := merged.ProtoDetails() + require.Len(t, details, 2) + badRequest := details[0].(*errdetails.BadRequest) + require.IsType(t, &errdetails.RetryInfo{}, details[1]) + require.Len(t, badRequest.FieldViolations, 3) + require.True(t, proto.Equal(first.FieldViolations[0], badRequest.FieldViolations[0])) + require.True(t, proto.Equal(first.FieldViolations[0], badRequest.FieldViolations[2])) + require.Equal(t, []byte{0x50, 1, 0x58, 3}, []byte(badRequest.ProtoReflect().GetUnknown())) + badRequest.FieldViolations[0].LocalizedMessage.Message = "mutated" + first.FieldViolations[0].Reason = "mutated" + require.Equal(t, "Name erforderlich", merged.ProtoDetails()[0].(*errdetails.BadRequest).FieldViolations[0].LocalizedMessage.Message) + require.Equal(t, "MISSING", original.ProtoDetails()[0].(*errdetails.BadRequest).FieldViolations[0].Reason) + + // Multiple wire-level BadRequest messages normalize through both decoders. + wireFirst := original.ProtoDetails()[0].(*errdetails.BadRequest) + wireSecond := &errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{{Field: "email", Description: "required"}}} + s, err := grpcerr.ToStatus(t.Context(), original, false).WithDetails(wireSecond) + require.NoError(t, err) + decoded, ok := grpcerr.FromStatus(s) + require.True(t, ok) + require.Len(t, decoded.ProtoDetails(), 1) + expected := proto.Clone(wireFirst).(*errdetails.BadRequest) + proto.Merge(expected, wireSecond) + require.True(t, proto.Equal(expected, decoded.ProtoDetails()[0])) + require.Len(t, grpcerr.ToStatus(t.Context(), decoded, false).Details(), 2) + + body := httperr.NewBody(original, httperr.Options{}) + other := httperr.NewBody(herodot.Internal.New(herodot.ProtoDetail(wireSecond)), httperr.Options{}) + body.Error.Details = append(body.Error.Details, other.Error.Details[1]) + raw, err := json.Marshal(body) + require.NoError(t, err) + decodedHTTP, ok := httperr.DecodeBody(raw) + require.True(t, ok) + require.Len(t, decodedHTTP.ProtoDetails(), 1) + require.Len(t, decodedHTTP.ProtoDetails()[0].(*errdetails.BadRequest).FieldViolations, 2) + require.Equal(t, "MISSING", decodedHTTP.ProtoDetails()[0].(*errdetails.BadRequest).FieldViolations[0].Reason) + require.Len(t, httperr.NewBody(decodedHTTP, httperr.Options{}).Error.Details, 2) +}