Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions statusx/vproto.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"log/slog"
"net/http"
"net/url"
"strings"

"connectrpc.com/connect"
Expand All @@ -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"
}
Expand Down Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions statusx/vproto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
Expand Down
Loading