diff --git a/statusx/vproto.go b/statusx/vproto.go index 5b8a7ddf..63fcf45f 100644 --- a/statusx/vproto.go +++ b/statusx/vproto.go @@ -5,6 +5,7 @@ import ( "context" "log/slog" "net/http" + "net/url" "strings" "connectrpc.com/connect" @@ -26,6 +27,16 @@ var AllowHeaders = []string{ http.CanonicalHeaderKey(HeaderEnsureConnectError), } +// HeaderStatusMetadata carries the error's metadata (from statusx.WithMetadata) +// on the HTTP response. Since vproto.ValidationError has no metadata field, the +// metadata is exposed out-of-band: a JSON-encoded map[string]string, URL-escaped, +// set on this header. It is only present when the error actually carries metadata. +const HeaderStatusMetadata = "x-status-metadata" + +var ExposedHeaders = []string{ + http.CanonicalHeaderKey(HeaderStatusMetadata), +} + func EnsureConnectError(ctx context.Context) bool { return metadata.ExtractIncoming(ctx).Get(HeaderEnsureConnectError) == "true" } @@ -117,6 +128,12 @@ func WriteVProtoHTTPError(err error, w http.ResponseWriter, r *http.Request) (xe } } + // vproto.ValidationError has no metadata field, so expose the error's + // metadata (from statusx.WithMetadata) out-of-band on a response header. + if md := errorInfo.GetMetadata(); len(md) > 0 { + w.Header().Set(HeaderStatusMetadata, url.QueryEscape(jsonx.MustMarshalX[string](md))) + } + isJSON := isMimeTypeJSON(w.Header().Get(httpx.HeaderContentType)) if w.Header().Get(httpx.HeaderContentType) == "" { isJSON = shouldReturnJSON(r) diff --git a/statusx/vproto_test.go b/statusx/vproto_test.go index 42f4a714..6871144b 100644 --- a/statusx/vproto_test.go +++ b/statusx/vproto_test.go @@ -3,9 +3,11 @@ package statusx import ( "net/http" "net/http/httptest" + "net/url" "strings" "testing" + "github.com/qor5/x/v3/jsonx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -25,6 +27,53 @@ func TestWriteVProtoHTTPError_JSON(t *testing.T) { assert.Contains(t, w.Body.String(), "INVALID_ARGUMENT") } +func TestWriteVProtoHTTPError_MetadataHeader(t *testing.T) { + md := map[string]string{"order_id": "123", "retry_after": "30s"} + tests := []struct { + name string + err error + wantMetadata map[string]string // nil means no metadata header expected + }{ + { + name: "with metadata sets header", + err: New(codes.InvalidArgument, "INVALID_ARGUMENT", "invalid").WithMetadata(md).Err(), + wantMetadata: md, + }, + { + name: "without metadata omits header", + err: BadRequest(NewFieldViolation("email", "REQUIRED", "Email is required")).Err(), + wantMetadata: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", strings.NewReader("req")) + req.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + + werr := WriteVProtoHTTPError(tt.err, w, req) + require.NoError(t, werr) + + encoded := w.Header().Get(HeaderStatusMetadata) + if tt.wantMetadata == nil { + assert.Empty(t, encoded) + return + } + + require.NotEmpty(t, encoded) + + // Reverse the encoding done by WriteVProtoHTTPError: URL-unescape + // then JSON-unmarshal the header value back into a metadata map. + data, derr := url.QueryUnescape(encoded) + require.NoError(t, derr) + var decoded map[string]string + require.NoError(t, jsonx.Unmarshal([]byte(data), &decoded)) + assert.Equal(t, tt.wantMetadata, decoded) + }) + } +} + func TestWriteVProtoHTTPError_MarshalFailureFallbackJSON(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/test", strings.NewReader("req")) w := httptest.NewRecorder()