feat: user service
This commit is contained in:
+34
-4
@@ -23,6 +23,8 @@ Optional integrations:
|
||||
- `GATEWAY_ADMIN_HTTP_ADDR` enables the private `/metrics` listener;
|
||||
- `GATEWAY_AUTH_SERVICE_BASE_URL` enables real public auth handling through
|
||||
Auth / Session Service public HTTP;
|
||||
- `GATEWAY_USER_SERVICE_BASE_URL` enables direct authenticated self-service
|
||||
routing to User Service internal HTTP;
|
||||
- injected downstream routes are required for successful `ExecuteCommand`.
|
||||
|
||||
Operational caveats:
|
||||
@@ -118,6 +120,10 @@ The public auth JSON contract uses a challenge-token flow:
|
||||
key for the device session being created.
|
||||
`time_zone` is the client-selected IANA time zone name forwarded unchanged to
|
||||
`Auth / Session Service`.
|
||||
The current create-path source of truth for `preferred_language` is still the
|
||||
temporary authsession-to-user rollout using `"en"`. Gateway-side language
|
||||
derivation is a later rollout. The public `confirm-email-code` DTO itself
|
||||
remains unchanged.
|
||||
|
||||
These routes remain unauthenticated and delegate only through an injected
|
||||
`AuthServiceClient`.
|
||||
@@ -322,10 +328,24 @@ The authenticated transport uses a split contract:
|
||||
- signatures are computed over canonical envelope fields and a hash of raw
|
||||
FlatBuffers bytes.
|
||||
|
||||
The gateway treats authenticated request `payload_bytes` as opaque business
|
||||
data.
|
||||
It verifies integrity and forwards verified bytes downstream without rewriting
|
||||
them.
|
||||
The gateway verifies authenticated payload bytes before any downstream call.
|
||||
Most downstream routes may still treat those bytes as opaque, but the gateway
|
||||
is also allowed to transcode verified FlatBuffers payloads into trusted
|
||||
downstream REST/JSON calls when the concrete downstream contract requires it.
|
||||
|
||||
The current direct `Gateway -> User` self-service boundary uses that pattern:
|
||||
|
||||
- external message types:
|
||||
- `user.account.get`
|
||||
- `user.profile.update`
|
||||
- `user.settings.update`
|
||||
- external payloads and responses:
|
||||
- FlatBuffers
|
||||
- internal downstream transport:
|
||||
- strict REST/JSON to User Service
|
||||
- business error projection:
|
||||
- gateway `result_code`
|
||||
- FlatBuffers error payload mirroring User Service `code` and `message`
|
||||
|
||||
The request envelope version literal is `v1`.
|
||||
`payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`.
|
||||
@@ -965,6 +985,11 @@ failing process startup.
|
||||
Resolves the target downstream service or adapter by the full exact-match
|
||||
`message_type` literal.
|
||||
|
||||
The default `cmd/gateway` wiring keeps the reserved `user.*` self-service
|
||||
message types mounted even when `GATEWAY_USER_SERVICE_BASE_URL` is unset. In
|
||||
that configuration they fail closed as dependency-unavailable instead of
|
||||
falling through to a generic route miss.
|
||||
|
||||
### DownstreamClient
|
||||
|
||||
Executes a verified authenticated command against a downstream internal service
|
||||
@@ -972,6 +997,11 @@ and returns response payload bytes plus a stable opaque result code.
|
||||
An empty or whitespace-only result code is treated as an internal downstream
|
||||
contract violation.
|
||||
|
||||
Downstream clients may be pure pass-through adapters or gateway-owned
|
||||
transcoding adapters. The current User Service adapter decodes authenticated
|
||||
FlatBuffers payloads, calls the trusted internal REST API, and re-encodes the
|
||||
result into FlatBuffers before the signed gateway response is emitted.
|
||||
|
||||
### EventSubscriber
|
||||
|
||||
Subscribes to internal pub/sub topics used for:
|
||||
|
||||
+5
-2
@@ -3,5 +3,8 @@
|
||||
## 1. Suggest User's Preferred Language when registering a new User
|
||||
|
||||
Upon user's device/session registration flow, `preferred_language` value
|
||||
must be obtained via existing [geoip](../pkg/geoip) package by returned Country.
|
||||
When geoip feils to return country by ip, fallback is `en` language.
|
||||
must be obtained via existing [geoip](../pkg/geoip) package by returned
|
||||
country.
|
||||
The derived value must be emitted as a valid BCP 47 language tag because
|
||||
`User Service` now validates that contract semantically on create.
|
||||
When geoip fails to return country by IP, fallback is `en`.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"galaxy/gateway/internal/authn"
|
||||
"galaxy/gateway/internal/config"
|
||||
"galaxy/gateway/internal/downstream"
|
||||
"galaxy/gateway/internal/downstream/userservice"
|
||||
"galaxy/gateway/internal/events"
|
||||
"galaxy/gateway/internal/grpcapi"
|
||||
"galaxy/gateway/internal/logging"
|
||||
@@ -184,12 +185,27 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
||||
)
|
||||
}
|
||||
|
||||
userRoutes, closeUserServiceRoutes, err := userservice.NewRoutes(cfg.UserService.BaseURL)
|
||||
if err != nil {
|
||||
closeErr := errors.Join(
|
||||
fallbackSessionCache.Close(),
|
||||
replayStore.Close(),
|
||||
sessionSubscriber.Close(),
|
||||
clientEventSubscriber.Close(),
|
||||
)
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: user service routes: %w", err),
|
||||
closeErr,
|
||||
)
|
||||
}
|
||||
|
||||
cleanup := func() error {
|
||||
return errors.Join(
|
||||
fallbackSessionCache.Close(),
|
||||
replayStore.Close(),
|
||||
sessionSubscriber.Close(),
|
||||
clientEventSubscriber.Close(),
|
||||
closeUserServiceRoutes(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -227,7 +243,7 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
||||
|
||||
return grpcapi.ServerDependencies{
|
||||
Service: grpcapi.NewFanOutPushStreamService(pushHub, responseSigner, nil, logger),
|
||||
Router: downstream.NewStaticRouter(nil),
|
||||
Router: downstream.NewStaticRouter(userRoutes),
|
||||
ResponseSigner: responseSigner,
|
||||
SessionCache: sessionCache,
|
||||
ReplayStore: replayStore,
|
||||
|
||||
@@ -89,6 +89,26 @@ Example `ExecuteCommandResponse`:
|
||||
}
|
||||
```
|
||||
|
||||
Example authenticated self-service request metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"deviceSessionId": "device-session-123",
|
||||
"messageType": "user.account.get",
|
||||
"timestampMs": "1775121600000",
|
||||
"requestId": "request-account-123",
|
||||
"payloadBytes": "RkxBVEJVRkZFUlNfVVNFUl9SRVFVRVNU",
|
||||
"payloadHash": "5fY6Q8V9mK8x2B7v6v0V0m0i1rQ2QF0rQ8V1Yt1r8Ys=",
|
||||
"signature": "3o4v8f3h0Y6I0x1bS7zY+8m0bV1Lk4D3yq8J2n8F1rD7yK9v8M1Q0w2s4a6f8d0Q0m3L6y8R1t5w7x9z0a2cA=="
|
||||
}
|
||||
```
|
||||
|
||||
The external payload remains FlatBuffers. The current `Gateway -> User`
|
||||
self-service adapter decodes that payload, calls the trusted internal
|
||||
User Service REST API, then re-encodes the returned account aggregate or error
|
||||
envelope back into FlatBuffers before signing the response.
|
||||
|
||||
Example bootstrap `GatewayEvent` sent after `SubscribeEvents` opens:
|
||||
|
||||
```json
|
||||
|
||||
@@ -52,6 +52,24 @@ sequenceDiagram
|
||||
Gateway-->>Client: ExecuteCommandResponse + signature
|
||||
```
|
||||
|
||||
## Direct Gateway -> User Self-Service Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Gateway
|
||||
participant User as User Service
|
||||
|
||||
Client->>Gateway: ExecuteCommand(user.account.get | user.profile.update | user.settings.update)
|
||||
Gateway->>Gateway: verify envelope + session + signature + replay
|
||||
Gateway->>Gateway: decode FlatBuffers payload
|
||||
Gateway->>User: trusted REST/JSON internal request
|
||||
User-->>Gateway: JSON account aggregate or JSON error envelope
|
||||
Gateway->>Gateway: encode FlatBuffers success or error payload
|
||||
Gateway->>Gateway: sign response
|
||||
Gateway-->>Client: ExecuteCommandResponse(result_code, payload_bytes, signature)
|
||||
```
|
||||
|
||||
## SubscribeEvents Lifecycle
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -55,5 +55,7 @@ Notes:
|
||||
- The admin listener is optional and serves only Prometheus text metrics.
|
||||
- Public auth routing stays available without an upstream adapter, but returns
|
||||
`503 service_unavailable`.
|
||||
- Authenticated gRPC starts with an empty static router; `ExecuteCommand`
|
||||
remains `UNIMPLEMENTED` until downstream routes are injected.
|
||||
- The default runtime reserves direct `user.*` authenticated self-service
|
||||
routes. When `GATEWAY_USER_SERVICE_BASE_URL` is unset those routes stay
|
||||
mounted but fail closed as dependency-unavailable instead of returning a
|
||||
route miss.
|
||||
|
||||
+22
-22
@@ -6,22 +6,22 @@ require (
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1
|
||||
buf.build/go/protovalidate v1.1.3
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/getkin/kin-openapi v0.134.0
|
||||
github.com/getkin/kin-openapi v0.135.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/google/flatbuffers v25.12.19+incompatible
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0
|
||||
go.opentelemetry.io/otel/metric v1.42.0
|
||||
go.opentelemetry.io/otel/sdk v1.42.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0
|
||||
go.opentelemetry.io/otel/trace v1.42.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
@@ -32,24 +32,24 @@ require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.2 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/cel-go v0.27.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -64,15 +64,15 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/oasdiff/yaml v0.0.9 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
@@ -81,12 +81,12 @@ require (
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
|
||||
+24
-48
@@ -16,12 +16,10 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -35,10 +33,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU=
|
||||
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -56,12 +52,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
@@ -106,12 +100,9 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus=
|
||||
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
|
||||
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -124,8 +115,7 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
@@ -161,32 +151,20 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
@@ -198,12 +176,10 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
|
||||
@@ -45,6 +45,11 @@ const (
|
||||
// public-auth delegation.
|
||||
authServiceBaseURLEnvVar = "GATEWAY_AUTH_SERVICE_BASE_URL"
|
||||
|
||||
// userServiceBaseURLEnvVar names the environment variable that configures
|
||||
// the optional User Service internal HTTP base URL used by authenticated
|
||||
// gateway self-service delegation.
|
||||
userServiceBaseURLEnvVar = "GATEWAY_USER_SERVICE_BASE_URL"
|
||||
|
||||
// adminHTTPAddrEnvVar names the environment variable that configures the
|
||||
// private admin HTTP listener address. When it is empty, the admin listener
|
||||
// remains disabled.
|
||||
@@ -479,6 +484,15 @@ type AuthServiceConfig struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// UserServiceConfig describes the optional authenticated self-service upstream
|
||||
// used by the gateway runtime.
|
||||
type UserServiceConfig struct {
|
||||
// BaseURL is the absolute base URL of the User Service internal HTTP API.
|
||||
// When BaseURL is empty, the gateway keeps using its built-in unavailable
|
||||
// downstream adapter for the reserved `user.*` routes.
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// AdminHTTPConfig describes the private operational HTTP listener used for
|
||||
// metrics exposure. The listener remains disabled when Addr is empty.
|
||||
type AdminHTTPConfig struct {
|
||||
@@ -610,6 +624,10 @@ type Config struct {
|
||||
// Session Service.
|
||||
AuthService AuthServiceConfig
|
||||
|
||||
// UserService configures the optional authenticated self-service
|
||||
// delegation to User Service.
|
||||
UserService UserServiceConfig
|
||||
|
||||
// AdminHTTP configures the optional private admin listener used for metrics
|
||||
// exposure.
|
||||
AdminHTTP AdminHTTPConfig
|
||||
@@ -791,6 +809,13 @@ func DefaultAuthServiceConfig() AuthServiceConfig {
|
||||
return AuthServiceConfig{}
|
||||
}
|
||||
|
||||
// DefaultUserServiceConfig returns the default authenticated self-service
|
||||
// upstream settings. The zero value keeps the built-in unavailable adapter
|
||||
// active for reserved `user.*` routes.
|
||||
func DefaultUserServiceConfig() UserServiceConfig {
|
||||
return UserServiceConfig{}
|
||||
}
|
||||
|
||||
// LoadFromEnv loads Config from the process environment, applies defaults for
|
||||
// omitted settings, and validates the resulting values.
|
||||
func LoadFromEnv() (Config, error) {
|
||||
@@ -799,6 +824,7 @@ func LoadFromEnv() (Config, error) {
|
||||
Logging: DefaultLoggingConfig(),
|
||||
PublicHTTP: DefaultPublicHTTPConfig(),
|
||||
AuthService: DefaultAuthServiceConfig(),
|
||||
UserService: DefaultUserServiceConfig(),
|
||||
AdminHTTP: DefaultAdminHTTPConfig(),
|
||||
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
|
||||
SessionCacheRedis: DefaultSessionCacheRedisConfig(),
|
||||
@@ -856,6 +882,11 @@ func LoadFromEnv() (Config, error) {
|
||||
cfg.AuthService.BaseURL = rawAuthServiceBaseURL
|
||||
}
|
||||
|
||||
rawUserServiceBaseURL, ok := os.LookupEnv(userServiceBaseURLEnvVar)
|
||||
if ok {
|
||||
cfg.UserService.BaseURL = rawUserServiceBaseURL
|
||||
}
|
||||
|
||||
rawAdminHTTPAddr, ok := os.LookupEnv(adminHTTPAddrEnvVar)
|
||||
if ok {
|
||||
cfg.AdminHTTP.Addr = rawAdminHTTPAddr
|
||||
@@ -1124,6 +1155,17 @@ func LoadFromEnv() (Config, error) {
|
||||
}
|
||||
cfg.AuthService.BaseURL = strings.TrimRight(parsedAuthServiceBaseURL.String(), "/")
|
||||
}
|
||||
cfg.UserService.BaseURL = strings.TrimSpace(cfg.UserService.BaseURL)
|
||||
if cfg.UserService.BaseURL != "" {
|
||||
parsedUserServiceBaseURL, err := url.Parse(cfg.UserService.BaseURL)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load gateway config: parse %s: %w", userServiceBaseURLEnvVar, err)
|
||||
}
|
||||
if parsedUserServiceBaseURL.Scheme == "" || parsedUserServiceBaseURL.Host == "" {
|
||||
return Config{}, fmt.Errorf("load gateway config: %s must be an absolute URL", userServiceBaseURLEnvVar)
|
||||
}
|
||||
cfg.UserService.BaseURL = strings.TrimRight(parsedUserServiceBaseURL.String(), "/")
|
||||
}
|
||||
if addr := strings.TrimSpace(cfg.AdminHTTP.Addr); addr != "" {
|
||||
cfg.AdminHTTP.Addr = addr
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,8 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var configEnvMu sync.Mutex
|
||||
|
||||
func TestLoadFromEnv(t *testing.T) {
|
||||
customResponseSignerPrivateKeyPEMPath := new(string)
|
||||
*customResponseSignerPrivateKeyPEMPath = writeTestResponseSignerPEMFile(t)
|
||||
@@ -27,6 +30,9 @@ func TestLoadFromEnv(t *testing.T) {
|
||||
customAuthServiceBaseURL := new(string)
|
||||
*customAuthServiceBaseURL = " http://127.0.0.1:8082/ "
|
||||
|
||||
customUserServiceBaseURL := new(string)
|
||||
*customUserServiceBaseURL = " http://127.0.0.1:8083/ "
|
||||
|
||||
customAuthenticatedGRPCAddr := new(string)
|
||||
*customAuthenticatedGRPCAddr = "127.0.0.1:9191"
|
||||
|
||||
@@ -80,6 +86,7 @@ func TestLoadFromEnv(t *testing.T) {
|
||||
shutdownTimeout *string
|
||||
publicHTTPAddr *string
|
||||
authServiceBaseURL *string
|
||||
userServiceBaseURL *string
|
||||
authenticatedGRPCAddr *string
|
||||
authenticatedGRPCFreshnessWindow *string
|
||||
sessionCacheRedisAddr *string
|
||||
@@ -217,6 +224,40 @@ func TestLoadFromEnv(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom user service base url",
|
||||
userServiceBaseURL: customUserServiceBaseURL,
|
||||
sessionCacheRedisAddr: customSessionCacheRedisAddr,
|
||||
responseSignerPrivateKeyPEMPath: customResponseSignerPrivateKeyPEMPath,
|
||||
want: Config{
|
||||
ShutdownTimeout: 5 * time.Second,
|
||||
Logging: DefaultLoggingConfig(),
|
||||
PublicHTTP: DefaultPublicHTTPConfig(),
|
||||
UserService: UserServiceConfig{
|
||||
BaseURL: "http://127.0.0.1:8083",
|
||||
},
|
||||
AdminHTTP: DefaultAdminHTTPConfig(),
|
||||
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
|
||||
SessionCacheRedis: SessionCacheRedisConfig{
|
||||
Addr: "127.0.0.1:6379",
|
||||
DB: defaultSessionCacheRedisDB,
|
||||
KeyPrefix: defaultSessionCacheRedisKeyPrefix,
|
||||
LookupTimeout: defaultSessionCacheRedisLookupTimeout,
|
||||
},
|
||||
ReplayRedis: DefaultReplayRedisConfig(),
|
||||
SessionEventsRedis: SessionEventsRedisConfig{
|
||||
Stream: "gateway:session_events",
|
||||
ReadBlockTimeout: defaultSessionEventsRedisReadBlockTimeout,
|
||||
},
|
||||
ClientEventsRedis: ClientEventsRedisConfig{
|
||||
Stream: "gateway:client_events",
|
||||
ReadBlockTimeout: defaultClientEventsRedisReadBlockTimeout,
|
||||
},
|
||||
ResponseSigner: ResponseSignerConfig{
|
||||
PrivateKeyPEMPath: *customResponseSignerPrivateKeyPEMPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom authenticated grpc address",
|
||||
authenticatedGRPCAddr: customAuthenticatedGRPCAddr,
|
||||
@@ -368,6 +409,7 @@ func TestLoadFromEnv(t *testing.T) {
|
||||
shutdownTimeoutEnvVar,
|
||||
publicHTTPAddrEnvVar,
|
||||
authServiceBaseURLEnvVar,
|
||||
userServiceBaseURLEnvVar,
|
||||
authenticatedGRPCAddrEnvVar,
|
||||
authenticatedGRPCFreshnessWindowEnvVar,
|
||||
sessionCacheRedisAddrEnvVar,
|
||||
@@ -379,6 +421,7 @@ func TestLoadFromEnv(t *testing.T) {
|
||||
setEnvValue(t, shutdownTimeoutEnvVar, tt.shutdownTimeout)
|
||||
setEnvValue(t, publicHTTPAddrEnvVar, tt.publicHTTPAddr)
|
||||
setEnvValue(t, authServiceBaseURLEnvVar, tt.authServiceBaseURL)
|
||||
setEnvValue(t, userServiceBaseURLEnvVar, tt.userServiceBaseURL)
|
||||
setEnvValue(t, authenticatedGRPCAddrEnvVar, tt.authenticatedGRPCAddr)
|
||||
setEnvValue(t, authenticatedGRPCFreshnessWindowEnvVar, tt.authenticatedGRPCFreshnessWindow)
|
||||
setEnvValue(t, sessionCacheRedisAddrEnvVar, tt.sessionCacheRedisAddr)
|
||||
@@ -492,7 +535,7 @@ func TestLoadFromEnvOperationalSettings(t *testing.T) {
|
||||
restoreEnvs(t, append(
|
||||
append(
|
||||
append(
|
||||
append(operationalEnvVars(), sessionCacheRedisEnvVars()...),
|
||||
append(append(operationalEnvVars(), authServiceBaseURLEnvVar, userServiceBaseURLEnvVar), sessionCacheRedisEnvVars()...),
|
||||
sessionEventsRedisEnvVars()...,
|
||||
),
|
||||
clientEventsRedisEnvVars()...,
|
||||
@@ -563,6 +606,8 @@ func TestLoadFromEnvAuthService(t *testing.T) {
|
||||
|
||||
restoreEnvs(t,
|
||||
authServiceBaseURLEnvVar,
|
||||
userServiceBaseURLEnvVar,
|
||||
logLevelEnvVar,
|
||||
sessionCacheRedisAddrEnvVar,
|
||||
sessionEventsRedisStreamEnvVar,
|
||||
clientEventsRedisStreamEnvVar,
|
||||
@@ -581,6 +626,72 @@ func TestLoadFromEnvAuthService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvUserService(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
customSessionCacheRedisAddr := new(string)
|
||||
*customSessionCacheRedisAddr = "127.0.0.1:6379"
|
||||
|
||||
customSessionEventsRedisStream := new(string)
|
||||
*customSessionEventsRedisStream = "gateway:session_events"
|
||||
|
||||
customClientEventsRedisStream := new(string)
|
||||
*customClientEventsRedisStream = "gateway:client_events"
|
||||
|
||||
customResponseSignerPrivateKeyPEMPath := new(string)
|
||||
*customResponseSignerPrivateKeyPEMPath = writeTestResponseSignerPEMFile(t)
|
||||
|
||||
invalidRelativeURL := new(string)
|
||||
*invalidRelativeURL = "/user"
|
||||
|
||||
invalidURL := new(string)
|
||||
*invalidURL = "://bad"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value *string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "relative url rejected",
|
||||
value: invalidRelativeURL,
|
||||
wantErr: userServiceBaseURLEnvVar + " must be an absolute URL",
|
||||
},
|
||||
{
|
||||
name: "malformed url rejected",
|
||||
value: invalidURL,
|
||||
wantErr: "parse " + userServiceBaseURLEnvVar,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
restoreEnvs(t,
|
||||
authServiceBaseURLEnvVar,
|
||||
userServiceBaseURLEnvVar,
|
||||
logLevelEnvVar,
|
||||
sessionCacheRedisAddrEnvVar,
|
||||
sessionEventsRedisStreamEnvVar,
|
||||
clientEventsRedisStreamEnvVar,
|
||||
responseSignerPrivateKeyPEMPathEnvVar,
|
||||
)
|
||||
setEnvValue(t, userServiceBaseURLEnvVar, tt.value)
|
||||
setEnvValue(t, sessionCacheRedisAddrEnvVar, customSessionCacheRedisAddr)
|
||||
setEnvValue(t, sessionEventsRedisStreamEnvVar, customSessionEventsRedisStream)
|
||||
setEnvValue(t, clientEventsRedisStreamEnvVar, customClientEventsRedisStream)
|
||||
setEnvValue(t, responseSignerPrivateKeyPEMPathEnvVar, customResponseSignerPrivateKeyPEMPath)
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvAuthenticatedGRPCAntiAbuse(t *testing.T) {
|
||||
customSessionCacheRedisAddr := new(string)
|
||||
*customSessionCacheRedisAddr = "127.0.0.1:6379"
|
||||
@@ -1276,6 +1387,9 @@ func setEnvValue(t *testing.T, envVar string, value *string) {
|
||||
func restoreEnvs(t *testing.T, envVars ...string) {
|
||||
t.Helper()
|
||||
|
||||
configEnvMu.Lock()
|
||||
t.Cleanup(configEnvMu.Unlock)
|
||||
|
||||
for _, envVar := range envVars {
|
||||
restoreEnv(t, envVar)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// Package userservice implements the authenticated Gateway -> User Service
|
||||
// self-service downstream adapter.
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
usermodel "galaxy/model/user"
|
||||
"galaxy/transcoder"
|
||||
)
|
||||
|
||||
const (
|
||||
getMyAccountResultCodeOK = "ok"
|
||||
|
||||
userServiceAccountPathSuffix = "/account"
|
||||
userServiceProfilePathSuffix = "/profile"
|
||||
userServiceSettingsPathSuffix = "/settings"
|
||||
)
|
||||
|
||||
var stableErrorMessages = map[string]string{
|
||||
"invalid_request": "request is invalid",
|
||||
"subject_not_found": "subject not found",
|
||||
"conflict": "request conflicts with current state",
|
||||
"internal_error": "internal server error",
|
||||
}
|
||||
|
||||
// HTTPClient implements downstream.Client against the trusted internal User
|
||||
// Service REST API while preserving FlatBuffers at the external authenticated
|
||||
// gateway boundary.
|
||||
type HTTPClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPClient constructs one User Service downstream client backed by the
|
||||
// trusted internal REST API at baseURL.
|
||||
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
|
||||
transport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("new user service HTTP client: default transport is not *http.Transport")
|
||||
}
|
||||
|
||||
return newHTTPClient(baseURL, &http.Client{
|
||||
Transport: transport.Clone(),
|
||||
})
|
||||
}
|
||||
|
||||
func newHTTPClient(baseURL string, httpClient *http.Client) (*HTTPClient, error) {
|
||||
if httpClient == nil {
|
||||
return nil, errors.New("new user service HTTP client: http client must not be nil")
|
||||
}
|
||||
|
||||
trimmedBaseURL := strings.TrimSpace(baseURL)
|
||||
if trimmedBaseURL == "" {
|
||||
return nil, errors.New("new user service HTTP client: base URL must not be empty")
|
||||
}
|
||||
|
||||
parsedBaseURL, err := url.Parse(strings.TrimRight(trimmedBaseURL, "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new user service HTTP client: parse base URL: %w", err)
|
||||
}
|
||||
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
|
||||
return nil, errors.New("new user service HTTP client: base URL must be absolute")
|
||||
}
|
||||
|
||||
return &HTTPClient{
|
||||
baseURL: parsedBaseURL.String(),
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases idle HTTP connections owned by the client transport.
|
||||
func (c *HTTPClient) Close() error {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
type idleCloser interface {
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
if transport, ok := c.httpClient.Transport.(idleCloser); ok {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteCommand routes one authenticated gateway command to the matching
|
||||
// trusted internal User Service self-service route.
|
||||
func (c *HTTPClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return downstream.UnaryResult{}, errors.New("execute user service command: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return downstream.UnaryResult{}, errors.New("execute user service command: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(command.UserID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute user service command: user_id must not be empty")
|
||||
}
|
||||
|
||||
switch command.MessageType {
|
||||
case usermodel.MessageTypeGetMyAccount:
|
||||
if _, err := transcoder.PayloadToGetMyAccountRequest(command.PayloadBytes); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeGetMyAccount(ctx, command.UserID)
|
||||
case usermodel.MessageTypeUpdateMyProfile:
|
||||
request, err := transcoder.PayloadToUpdateMyProfileRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUpdateMyProfile(ctx, command.UserID, request)
|
||||
case usermodel.MessageTypeUpdateMySettings:
|
||||
request, err := transcoder.PayloadToUpdateMySettingsRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUpdateMySettings(ctx, command.UserID, request)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command: unsupported message type %q", command.MessageType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HTTPClient) executeGetMyAccount(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
||||
payload, statusCode, err := c.doRequest(ctx, http.MethodGet, c.userPath(userID, userServiceAccountPathSuffix), nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute get my account: %w", err)
|
||||
}
|
||||
|
||||
return projectResponse(statusCode, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) executeUpdateMyProfile(ctx context.Context, userID string, request *usermodel.UpdateMyProfileRequest) (downstream.UnaryResult, error) {
|
||||
payload, statusCode, err := c.doRequest(ctx, http.MethodPost, c.userPath(userID, userServiceProfilePathSuffix), request)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute update my profile: %w", err)
|
||||
}
|
||||
|
||||
return projectResponse(statusCode, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) executeUpdateMySettings(ctx context.Context, userID string, request *usermodel.UpdateMySettingsRequest) (downstream.UnaryResult, error) {
|
||||
payload, statusCode, err := c.doRequest(ctx, http.MethodPost, c.userPath(userID, userServiceSettingsPathSuffix), request)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute update my settings: %w", err)
|
||||
}
|
||||
|
||||
return projectResponse(statusCode, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) doRequest(ctx context.Context, method string, targetURL string, requestBody any) ([]byte, int, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil, 0, errors.New("nil client")
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if requestBody != nil {
|
||||
payload, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(payload)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if requestBody != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
return payload, response.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) userPath(userID string, suffix string) string {
|
||||
return c.baseURL + "/api/v1/internal/users/" + url.PathEscape(userID) + suffix
|
||||
}
|
||||
|
||||
func projectResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusOK:
|
||||
var response usermodel.AccountResponse
|
||||
if err := decodeStrictJSONPayload(payload, &response); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
|
||||
payloadBytes, err := transcoder.AccountResponseToPayload(&response)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: getMyAccountResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
case statusCode >= 400 && statusCode <= 599:
|
||||
errorResponse, err := decodeUserServiceError(statusCode, payload)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err)
|
||||
}
|
||||
|
||||
payloadBytes, err := transcoder.ErrorResponseToPayload(errorResponse)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err)
|
||||
}
|
||||
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: errorResponse.Error.Code,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeUserServiceError(statusCode int, payload []byte) (*usermodel.ErrorResponse, error) {
|
||||
var response usermodel.ErrorResponse
|
||||
if err := decodeStrictJSONPayload(payload, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response.Error.Code = normalizeErrorCode(statusCode, response.Error.Code)
|
||||
response.Error.Message = normalizeErrorMessage(response.Error.Code, response.Error.Message)
|
||||
|
||||
if strings.TrimSpace(response.Error.Code) == "" {
|
||||
return nil, errors.New("missing error code")
|
||||
}
|
||||
if strings.TrimSpace(response.Error.Message) == "" {
|
||||
return nil, errors.New("missing error message")
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func normalizeErrorCode(statusCode int, code string) string {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
switch statusCode {
|
||||
case http.StatusBadRequest:
|
||||
return "invalid_request"
|
||||
case http.StatusNotFound:
|
||||
return "subject_not_found"
|
||||
case http.StatusConflict:
|
||||
return "conflict"
|
||||
default:
|
||||
return "internal_error"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeErrorMessage(code string, message string) string {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if stable, ok := stableErrorMessages[code]; ok {
|
||||
return stable
|
||||
}
|
||||
|
||||
return stableErrorMessages["internal_error"]
|
||||
}
|
||||
|
||||
func decodeStrictJSONPayload(payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return errors.New("unexpected trailing JSON input")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ downstream.Client = (*HTTPClient)(nil)
|
||||
@@ -0,0 +1,399 @@
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
usermodel "galaxy/model/user"
|
||||
"galaxy/transcoder"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewHTTPClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
wantURL string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "absolute URL is normalized",
|
||||
baseURL: " http://127.0.0.1:8081/ ",
|
||||
wantURL: "http://127.0.0.1:8081",
|
||||
},
|
||||
{
|
||||
name: "empty base URL is rejected",
|
||||
baseURL: " ",
|
||||
wantErr: "base URL must not be empty",
|
||||
},
|
||||
{
|
||||
name: "relative base URL is rejected",
|
||||
baseURL: "/relative",
|
||||
wantErr: "base URL must be absolute",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := NewHTTPClient(tt.baseURL)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantURL, client.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteGetMyAccountSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantResponse := sampleAccountResponse()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
require.Equal(t, http.MethodGet, request.Method)
|
||||
require.Equal(t, "/api/v1/internal/users/user-123/account", request.URL.Path)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(wantResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, getMyAccountResultCodeOK, result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToAccountResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wantResponse, decoded)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteUpdateMyProfileProjectsConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
require.Equal(t, http.MethodPost, request.Method)
|
||||
require.Equal(t, "/api/v1/internal/users/user-123/profile", request.URL.Path)
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"race_name":"Nova Prime"}`, string(body))
|
||||
|
||||
writer.WriteHeader(http.StatusConflict)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "conflict",
|
||||
Message: "request conflicts with current state",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{RaceName: "Nova Prime"})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeUpdateMyProfile,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "conflict", result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "conflict",
|
||||
Message: "request conflicts with current state",
|
||||
},
|
||||
}, decoded)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteUpdateMySettingsProjectsInvalidRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
require.Equal(t, http.MethodPost, request.Method)
|
||||
require.Equal(t, "/api/v1/internal/users/user-123/settings", request.URL.Path)
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"preferred_language":"bad","time_zone":"Mars/Base"}`, string(body))
|
||||
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "invalid_request",
|
||||
Message: "request is invalid",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.UpdateMySettingsRequestToPayload(&usermodel.UpdateMySettingsRequest{
|
||||
PreferredLanguage: "bad",
|
||||
TimeZone: "Mars/Base",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeUpdateMySettings,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", decoded.Error.Code)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandProjectsSubjectNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "subject_not_found",
|
||||
Message: "subject not found",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-missing",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "subject_not_found", result.ResultCode)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandMaps503ToUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusServiceUnavailable)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "service_unavailable",
|
||||
Message: "service is unavailable",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandUsesCallerContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
<-request.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err = client.ExecuteCommand(ctx, downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandRejectsMalformedSuccessPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
_, _ = writer.Write([]byte(`{"account":{"user_id":"user-123","unexpected":true}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decode success response")
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandRejectsUnsupportedMessageType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
|
||||
_, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: "user.unsupported",
|
||||
PayloadBytes: []byte("payload"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported message type")
|
||||
}
|
||||
|
||||
func TestNewRoutesReserveUserMessageTypesWhenUnconfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
routes, closeFn, err := NewRoutes("")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, closeFn())
|
||||
|
||||
router := downstream.NewStaticRouter(routes)
|
||||
for _, messageType := range []string{
|
||||
usermodel.MessageTypeGetMyAccount,
|
||||
usermodel.MessageTypeUpdateMyProfile,
|
||||
usermodel.MessageTypeUpdateMySettings,
|
||||
} {
|
||||
client, routeErr := router.Route(messageType)
|
||||
require.NoError(t, routeErr)
|
||||
|
||||
_, execErr := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: messageType,
|
||||
})
|
||||
require.Error(t, execErr)
|
||||
assert.ErrorIs(t, execErr, downstream.ErrDownstreamUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableClientReturnsDownstreamUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := unavailableClient{}.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable)
|
||||
}
|
||||
|
||||
func newTestHTTPClient(t *testing.T, server *httptest.Server) *HTTPClient {
|
||||
t.Helper()
|
||||
|
||||
client, err := newHTTPClient(server.URL, server.Client())
|
||||
require.NoError(t, err)
|
||||
return client
|
||||
}
|
||||
|
||||
func sampleAccountResponse() *usermodel.AccountResponse {
|
||||
now := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC)
|
||||
expiresAt := now.Add(30 * 24 * time.Hour)
|
||||
|
||||
return &usermodel.AccountResponse{
|
||||
Account: usermodel.Account{
|
||||
UserID: "user-123",
|
||||
Email: "pilot@example.com",
|
||||
RaceName: "Pilot Nova",
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "Europe/Kaliningrad",
|
||||
DeclaredCountry: "DE",
|
||||
Entitlement: usermodel.EntitlementSnapshot{
|
||||
PlanCode: "free",
|
||||
IsPaid: false,
|
||||
Source: "auth_registration",
|
||||
Actor: usermodel.ActorRef{Type: "service", ID: "user-service"},
|
||||
ReasonCode: "initial_free_entitlement",
|
||||
StartsAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
ActiveSanctions: []usermodel.ActiveSanction{
|
||||
{
|
||||
SanctionCode: "profile_update_block",
|
||||
Scope: "lobby",
|
||||
ReasonCode: "manual_block",
|
||||
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now,
|
||||
ExpiresAt: &expiresAt,
|
||||
},
|
||||
},
|
||||
ActiveLimits: []usermodel.ActiveLimit{
|
||||
{
|
||||
LimitCode: "max_owned_private_games",
|
||||
Value: 3,
|
||||
ReasonCode: "manual_override",
|
||||
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now,
|
||||
},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeUserServiceErrorNormalizesBlankFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
response, err := decodeUserServiceError(http.StatusBadRequest, []byte(`{"error":{"code":" ","message":" "}}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", response.Error.Code)
|
||||
assert.Equal(t, "request is invalid", response.Error.Message)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandRejectsNilContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
|
||||
_, err := client.ExecuteCommand(nil, downstream.AuthenticatedCommand{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nil context")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
usermodel "galaxy/model/user"
|
||||
)
|
||||
|
||||
var noOpClose = func() error { return nil }
|
||||
|
||||
// NewRoutes returns the reserved authenticated gateway routes owned by the
|
||||
// Gateway -> User self-service boundary.
|
||||
//
|
||||
// When baseURL is empty, the returned routes still reserve the stable
|
||||
// `user.*` message types but resolve them to a dependency-unavailable client
|
||||
// so callers receive the transport-level unavailable outcome instead of a
|
||||
// route-miss error.
|
||||
func NewRoutes(baseURL string) (map[string]downstream.Client, func() error, error) {
|
||||
client := downstream.Client(unavailableClient{})
|
||||
closeFn := noOpClose
|
||||
|
||||
if baseURL != "" {
|
||||
httpClient, err := NewHTTPClient(baseURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
client = httpClient
|
||||
closeFn = httpClient.Close
|
||||
}
|
||||
|
||||
return map[string]downstream.Client{
|
||||
usermodel.MessageTypeGetMyAccount: client,
|
||||
usermodel.MessageTypeUpdateMyProfile: client,
|
||||
usermodel.MessageTypeUpdateMySettings: client,
|
||||
}, closeFn, nil
|
||||
}
|
||||
|
||||
type unavailableClient struct{}
|
||||
|
||||
func (unavailableClient) ExecuteCommand(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
}
|
||||
|
||||
var _ downstream.Client = unavailableClient{}
|
||||
Reference in New Issue
Block a user