feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -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-"))
}