// 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)