Files
galaxy-game/notification/internal/adapters/userservice/client.go
T
2026-04-22 08:49:45 +02:00

244 lines
7.0 KiB
Go

// Package userservice provides the trusted internal User Service HTTP client
// used by Notification Service recipient enrichment.
package userservice
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"galaxy/notification/internal/service/acceptintent"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
const (
getUserByIDPathSuffix = "/api/v1/internal/users/%s"
subjectNotFoundErrorCode = "subject_not_found"
)
// Config configures one HTTP-backed User Service enrichment client.
type Config struct {
// BaseURL stores the absolute base URL of the trusted internal User Service
// HTTP API.
BaseURL string
// RequestTimeout bounds one outbound lookup request.
RequestTimeout time.Duration
}
// Client resolves Notification Service recipients through the trusted
// internal User Service HTTP API.
type Client struct {
baseURL string
requestTimeout time.Duration
httpClient *http.Client
closeIdleConnections func()
}
type getUserByIDResponse struct {
User userView `json:"user"`
}
type userView struct {
Email string `json:"email"`
PreferredLanguage string `json:"preferred_language"`
}
type errorEnvelope struct {
Error *errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// NewClient constructs a User Service client that uses repository-standard
// HTTP transport instrumentation through otelhttp.
func NewClient(cfg Config) (*Client, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("new notification user service client: default transport is not *http.Transport")
}
baseTransport := transport.Clone()
return newClient(
cfg,
&http.Client{Transport: otelhttp.NewTransport(baseTransport)},
baseTransport.CloseIdleConnections,
)
}
func newClient(cfg Config, httpClient *http.Client, closeIdleConnections func()) (*Client, error) {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return nil, errors.New("new notification user service client: base URL must not be empty")
case cfg.RequestTimeout <= 0:
return nil, errors.New("new notification user service client: request timeout must be positive")
case httpClient == nil:
return nil, errors.New("new notification user service client: http client must not be nil")
}
parsedBaseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"))
if err != nil {
return nil, fmt.Errorf("new notification user service client: parse base URL: %w", err)
}
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
return nil, errors.New("new notification user service client: base URL must be absolute")
}
return &Client{
baseURL: parsedBaseURL.String(),
requestTimeout: cfg.RequestTimeout,
httpClient: httpClient,
closeIdleConnections: closeIdleConnections,
}, nil
}
// Close releases idle HTTP connections owned by the client transport.
func (client *Client) Close() error {
if client == nil || client.closeIdleConnections == nil {
return nil
}
client.closeIdleConnections()
return nil
}
// GetUserByID resolves the current user email and preferred language for the
// supplied stable userID.
func (client *Client) GetUserByID(ctx context.Context, userID string) (acceptintent.UserRecord, error) {
if client == nil || client.httpClient == nil {
return acceptintent.UserRecord{}, errors.New("lookup user by id: nil client")
}
if ctx == nil {
return acceptintent.UserRecord{}, errors.New("lookup user by id: nil context")
}
if err := ctx.Err(); err != nil {
return acceptintent.UserRecord{}, err
}
if strings.TrimSpace(userID) == "" {
return acceptintent.UserRecord{}, errors.New("lookup user by id: user id must not be empty")
}
payload, statusCode, err := client.doRequest(ctx, http.MethodGet, fmt.Sprintf(getUserByIDPathSuffix, url.PathEscape(userID)))
if err != nil {
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: %w", userID, err)
}
switch statusCode {
case http.StatusOK:
var response getUserByIDResponse
if err := decodeJSONPayload(payload, &response); err != nil {
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: decode success response: %w", userID, err)
}
record := acceptintent.UserRecord{
Email: response.User.Email,
PreferredLanguage: response.User.PreferredLanguage,
}
if err := record.Validate(); err != nil {
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: invalid success response: %w", userID, err)
}
return record, nil
case http.StatusNotFound:
errorCode, err := decodeErrorCode(payload)
if err != nil {
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: decode error response: %w", userID, err)
}
if errorCode == subjectNotFoundErrorCode {
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: %w", userID, acceptintent.ErrRecipientNotFound)
}
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: unexpected error code %q for status %d", userID, errorCode, statusCode)
default:
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: unexpected HTTP status %d", userID, statusCode)
}
}
func (client *Client) doRequest(ctx context.Context, method string, requestPath string) ([]byte, int, error) {
attemptCtx, cancel := context.WithTimeout(ctx, client.requestTimeout)
defer cancel()
request, err := http.NewRequestWithContext(attemptCtx, method, client.baseURL+requestPath, nil)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
response, err := client.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 decodeErrorCode(payload []byte) (string, error) {
var envelope errorEnvelope
if err := decodeStrictJSONPayload(payload, &envelope); err != nil {
return "", err
}
if envelope.Error == nil {
return "", errors.New("missing error object")
}
if strings.TrimSpace(envelope.Error.Code) == "" {
return "", errors.New("missing error code")
}
return envelope.Error.Code, nil
}
func decodeJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
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
}
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 _ acceptintent.UserDirectory = (*Client)(nil)