312 lines
9.6 KiB
Go
312 lines
9.6 KiB
Go
// 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)
|