memos/server/router/api/v1/header_carrier.go
Steven 3d893a7394 fix(backend): implement protocol-agnostic header setting for dual gRPC/Connect-RPC support
Problem:
The codebase supports both native gRPC and Connect-RPC protocols, but auth
service was using grpc.SetHeader() which only works for native gRPC. This
caused "failed to set grpc header" errors when using Connect-RPC clients
(browsers using nice-grpc-web).

Solution:
- Created HeaderCarrier pattern for protocol-agnostic header setting
- HeaderCarrier stores headers in context for Connect-RPC requests
- Falls back to grpc.SetHeader for native gRPC requests
- Updated auth service to use SetResponseHeader() instead of grpc.SetHeader()
- Refactored Connect wrappers to use withHeaderCarrier() helper to eliminate
  code duplication

Additional fixes:
- Allow public methods when gRPC metadata is missing in ACL interceptor
- Properly handle ParseSessionCookieValue errors instead of ignoring them
- Fix buildSessionCookie to gracefully handle missing metadata

Files changed:
- server/router/api/v1/header_carrier.go: New protocol-agnostic header carrier
- server/router/api/v1/auth_service.go: Use SetResponseHeader, handle missing metadata
- server/router/api/v1/connect_services.go: Use withHeaderCarrier helper
- server/router/api/v1/acl.go: Allow public methods without metadata
- server/router/api/v1/connect_interceptors.go: Handle ParseSessionCookieValue errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 07:46:28 +08:00

122 lines
3.9 KiB
Go

package v1
import (
"context"
"connectrpc.com/connect"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
)
// headerCarrierKey is the context key for storing headers to be set in the response.
type headerCarrierKey struct{}
// HeaderCarrier stores headers that need to be set in the response.
//
// Problem: The codebase supports two protocols simultaneously:
// - Native gRPC: Uses grpc.SetHeader() to set response headers
// - Connect-RPC: Uses connect.Response.Header().Set() to set response headers
//
// Solution: HeaderCarrier provides a protocol-agnostic way to set headers.
// - Service methods call SetResponseHeader() regardless of protocol
// - For gRPC requests: SetResponseHeader uses grpc.SetHeader directly
// - For Connect requests: SetResponseHeader stores headers in HeaderCarrier
// - Connect wrappers extract headers from HeaderCarrier and apply to response
//
// This allows service methods to work with both protocols without knowing which one is being used.
type HeaderCarrier struct {
headers map[string]string
}
// newHeaderCarrier creates a new header carrier.
func newHeaderCarrier() *HeaderCarrier {
return &HeaderCarrier{
headers: make(map[string]string),
}
}
// Set adds a header to the carrier.
func (h *HeaderCarrier) Set(key, value string) {
h.headers[key] = value
}
// Get retrieves a header from the carrier.
func (h *HeaderCarrier) Get(key string) string {
return h.headers[key]
}
// All returns all headers.
func (h *HeaderCarrier) All() map[string]string {
return h.headers
}
// WithHeaderCarrier adds a header carrier to the context.
func WithHeaderCarrier(ctx context.Context) context.Context {
return context.WithValue(ctx, headerCarrierKey{}, newHeaderCarrier())
}
// GetHeaderCarrier retrieves the header carrier from the context.
// Returns nil if no carrier is present.
func GetHeaderCarrier(ctx context.Context) *HeaderCarrier {
if carrier, ok := ctx.Value(headerCarrierKey{}).(*HeaderCarrier); ok {
return carrier
}
return nil
}
// SetResponseHeader sets a header in the response.
//
// This function works for both gRPC and Connect protocols:
// - For gRPC: Uses grpc.SetHeader to set headers in gRPC metadata
// - For Connect: Stores in HeaderCarrier for Connect wrapper to apply later
//
// The protocol is automatically detected based on whether a HeaderCarrier
// exists in the context (injected by Connect wrappers).
func SetResponseHeader(ctx context.Context, key, value string) error {
// Try Connect first (check if we have a header carrier)
if carrier := GetHeaderCarrier(ctx); carrier != nil {
carrier.Set(key, value)
return nil
}
// Fall back to gRPC
return grpc.SetHeader(ctx, metadata.New(map[string]string{
key: value,
}))
}
// withHeaderCarrier is a helper for Connect service wrappers that need to set response headers.
//
// It injects a HeaderCarrier into the context, calls the service method,
// and applies any headers from the carrier to the Connect response.
//
// Usage in Connect wrappers:
//
// func (s *ConnectServiceHandler) CreateSession(ctx context.Context, req *connect.Request[...]) (*connect.Response[...], error) {
// return withHeaderCarrier(ctx, func(ctx context.Context) (*v1pb.CreateSessionResponse, error) {
// return s.APIV1Service.CreateSession(ctx, req.Msg)
// })
// }
func withHeaderCarrier[T proto.Message](ctx context.Context, fn func(context.Context) (T, error)) (*connect.Response[T], error) {
// Inject header carrier for Connect protocol
ctx = WithHeaderCarrier(ctx)
// Call the service method
resp, err := fn(ctx)
if err != nil {
return nil, convertGRPCError(err)
}
// Create Connect response
connectResp := connect.NewResponse(resp)
// Apply any headers set via the header carrier
if carrier := GetHeaderCarrier(ctx); carrier != nil {
for key, value := range carrier.All() {
connectResp.Header().Set(key, value)
}
}
return connectResp, nil
}