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