feat: game lobby service
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// Package httpcommon hosts cross-router HTTP middleware shared by the
|
||||
// Game Lobby Service public and internal listeners.
|
||||
package httpcommon
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/logging"
|
||||
)
|
||||
|
||||
// RequestIDHeader is the canonical HTTP header used to carry a
|
||||
// caller-supplied request id across service hops.
|
||||
const RequestIDHeader = "X-Request-Id"
|
||||
|
||||
// requestIDTokenBytes controls the entropy of generated request ids. Eight
|
||||
// bytes produce a 13-character base32 token, well above what is needed to
|
||||
// keep collisions vanishingly rare within any single service's logs.
|
||||
const requestIDTokenBytes = 8
|
||||
|
||||
// requestIDMaxLength caps the length of caller-supplied request ids so a
|
||||
// hostile or buggy upstream cannot blow up logs and trace attributes.
|
||||
const requestIDMaxLength = 128
|
||||
|
||||
// base32NoPadding mirrors the encoding used elsewhere in the lobby module
|
||||
// (see `internal/adapters/idgen`) so generated ids stay visually similar.
|
||||
var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// RequestID is the HTTP middleware that materialises the per-request
|
||||
// `request_id` for downstream loggers. It reads the X-Request-Id header
|
||||
// (case-insensitively); when the header is absent, malformed, or longer
|
||||
// than requestIDMaxLength it generates a fresh token from crypto/rand.
|
||||
// The id is stored on the request context via logging.WithRequestID and
|
||||
// echoed back on the response header.
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
if next == nil {
|
||||
panic("httpcommon: nil next handler")
|
||||
}
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestID := normalizeRequestID(request.Header.Get(RequestIDHeader))
|
||||
if requestID == "" {
|
||||
requestID = generateRequestID()
|
||||
}
|
||||
|
||||
writer.Header().Set(RequestIDHeader, requestID)
|
||||
|
||||
ctx := logging.WithRequestID(request.Context(), requestID)
|
||||
next.ServeHTTP(writer, request.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeRequestID returns a trimmed copy of value when it satisfies the
|
||||
// per-request constraints, otherwise the empty string. The empty return
|
||||
// signals that the middleware must generate a fresh id.
|
||||
func normalizeRequestID(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if len(trimmed) > requestIDMaxLength {
|
||||
return ""
|
||||
}
|
||||
for _, r := range trimmed {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// generateRequestID returns a fresh opaque id derived from crypto/rand.
|
||||
// Errors from the random source are vanishingly unlikely; the helper
|
||||
// returns the literal "fallback" on the impossible path so the middleware
|
||||
// remains panic-free.
|
||||
func generateRequestID() string {
|
||||
buf := make([]byte, requestIDTokenBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "rid-fallback"
|
||||
}
|
||||
return "rid-" + strings.ToLower(base32NoPadding.EncodeToString(buf))
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package httpcommon_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/lobby/internal/api/httpcommon"
|
||||
"galaxy/lobby/internal/logging"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRequestIDPropagatesIncomingHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var observed string
|
||||
handler := httpcommon.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observed = logging.RequestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
request.Header.Set(httpcommon.RequestIDHeader, "rid-test-1")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, "rid-test-1", observed)
|
||||
assert.Equal(t, "rid-test-1", recorder.Header().Get(httpcommon.RequestIDHeader))
|
||||
}
|
||||
|
||||
func TestRequestIDGeneratesWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var observed string
|
||||
handler := httpcommon.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observed = logging.RequestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/foo", nil))
|
||||
|
||||
require.NotEmpty(t, observed)
|
||||
assert.True(t, strings.HasPrefix(observed, "rid-"), "got %q", observed)
|
||||
assert.Equal(t, observed, recorder.Header().Get(httpcommon.RequestIDHeader))
|
||||
}
|
||||
|
||||
func TestRequestIDRejectsControlCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var observed string
|
||||
handler := httpcommon.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observed = logging.RequestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
request.Header.Set(httpcommon.RequestIDHeader, "bad\x00id")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
require.NotEqual(t, "bad\x00id", observed)
|
||||
assert.True(t, strings.HasPrefix(observed, "rid-"))
|
||||
}
|
||||
|
||||
func TestRequestIDRejectsOverlongValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var observed string
|
||||
handler := httpcommon.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observed = logging.RequestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
request.Header.Set(httpcommon.RequestIDHeader, strings.Repeat("a", 200))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
require.NotEqual(t, strings.Repeat("a", 200), observed)
|
||||
assert.True(t, strings.HasPrefix(observed, "rid-"))
|
||||
}
|
||||
Reference in New Issue
Block a user