// Package userservice provides runtime user-directory adapters for the // auth/session service. package userservice import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/domain/userresolution" "galaxy/authsession/internal/ports" ) const ( resolveByEmailPath = "/api/v1/internal/user-resolutions/by-email" existsByUserIDPath = "/api/v1/internal/users/%s/exists" ensureByEmailPath = "/api/v1/internal/users/ensure-by-email" blockByUserIDPath = "/api/v1/internal/users/%s/block" blockByEmailPath = "/api/v1/internal/user-blocks/by-email" ) // Config configures one HTTP-based UserDirectory client. type Config struct { // BaseURL is the absolute base URL of the future user-service internal // HTTP API. BaseURL string // RequestTimeout bounds each outbound user-service request. RequestTimeout time.Duration } // RESTClient implements ports.UserDirectory over a frozen internal REST // contract. type RESTClient struct { baseURL string requestTimeout time.Duration httpClient *http.Client } // NewRESTClient constructs a REST-backed UserDirectory adapter from cfg. func NewRESTClient(cfg Config) (*RESTClient, error) { transport := http.DefaultTransport.(*http.Transport).Clone() return newRESTClient(cfg, &http.Client{Transport: transport}) } func newRESTClient(cfg Config, httpClient *http.Client) (*RESTClient, error) { switch { case strings.TrimSpace(cfg.BaseURL) == "": return nil, errors.New("new user service REST client: base URL must not be empty") case cfg.RequestTimeout <= 0: return nil, errors.New("new user service REST client: request timeout must be positive") case httpClient == nil: return nil, errors.New("new user service REST 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 user service REST client: parse base URL: %w", err) } if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" { return nil, errors.New("new user service REST client: base URL must be absolute") } return &RESTClient{ baseURL: parsedBaseURL.String(), requestTimeout: cfg.RequestTimeout, httpClient: httpClient, }, nil } // Close releases idle HTTP connections owned by the client transport. func (c *RESTClient) 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 } // ResolveByEmail returns the current coarse user-resolution state for email // without creating any new user record. func (c *RESTClient) ResolveByEmail(ctx context.Context, email common.Email) (userresolution.Result, error) { if err := validateContext(ctx, "resolve by email"); err != nil { return userresolution.Result{}, err } if err := email.Validate(); err != nil { return userresolution.Result{}, fmt.Errorf("resolve by email: %w", err) } var response struct { Kind userresolution.Kind `json:"kind"` UserID string `json:"user_id,omitempty"` BlockReasonCode userresolution.BlockReasonCode `json:"block_reason_code,omitempty"` } if err := c.doJSON(ctx, "resolve by email", http.MethodPost, resolveByEmailPath, map[string]string{ "email": email.String(), }, &response, true); err != nil { return userresolution.Result{}, err } result := userresolution.Result{ Kind: response.Kind, UserID: common.UserID(response.UserID), BlockReasonCode: response.BlockReasonCode, } if err := result.Validate(); err != nil { return userresolution.Result{}, fmt.Errorf("resolve by email: %w", err) } return result, nil } // ExistsByUserID reports whether userID currently identifies a stored user // record. func (c *RESTClient) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) { if err := validateContext(ctx, "exists by user id"); err != nil { return false, err } if err := userID.Validate(); err != nil { return false, fmt.Errorf("exists by user id: %w", err) } var response struct { Exists bool `json:"exists"` } if err := c.doJSON(ctx, "exists by user id", http.MethodGet, fmt.Sprintf(existsByUserIDPath, url.PathEscape(userID.String())), nil, &response, true); err != nil { return false, err } return response.Exists, nil } // EnsureUserByEmail returns an existing user for email, creates a new user // when registration is allowed, or reports a blocked outcome. func (c *RESTClient) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) { if err := validateContext(ctx, "ensure user by email"); err != nil { return ports.EnsureUserResult{}, err } if err := email.Validate(); err != nil { return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err) } var response struct { Outcome ports.EnsureUserOutcome `json:"outcome"` UserID string `json:"user_id,omitempty"` BlockReasonCode userresolution.BlockReasonCode `json:"block_reason_code,omitempty"` } if err := c.doJSON(ctx, "ensure user by email", http.MethodPost, ensureByEmailPath, map[string]string{ "email": email.String(), }, &response, false); err != nil { return ports.EnsureUserResult{}, err } result := ports.EnsureUserResult{ Outcome: response.Outcome, UserID: common.UserID(response.UserID), BlockReasonCode: response.BlockReasonCode, } if err := result.Validate(); err != nil { return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err) } return result, nil } // BlockByUserID applies a block state to the user identified by input.UserID. // Unknown user ids wrap ports.ErrNotFound. func (c *RESTClient) BlockByUserID(ctx context.Context, input ports.BlockUserByIDInput) (ports.BlockUserResult, error) { if err := validateContext(ctx, "block by user id"); err != nil { return ports.BlockUserResult{}, err } if err := input.Validate(); err != nil { return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err) } payload, statusCode, err := c.doRequest(ctx, "block by user id", http.MethodPost, fmt.Sprintf(blockByUserIDPath, url.PathEscape(input.UserID.String())), map[string]string{ "reason_code": input.ReasonCode.String(), }, false) if err != nil { return ports.BlockUserResult{}, err } if statusCode == http.StatusNotFound { return ports.BlockUserResult{}, fmt.Errorf("block by user id %q: %w", input.UserID, ports.ErrNotFound) } if statusCode != http.StatusOK { return ports.BlockUserResult{}, fmt.Errorf("block by user id: unexpected HTTP status %d", statusCode) } var response struct { Outcome ports.BlockUserOutcome `json:"outcome"` UserID string `json:"user_id,omitempty"` } if err := decodeJSONPayload(payload, &response); err != nil { return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err) } result := ports.BlockUserResult{ Outcome: response.Outcome, UserID: common.UserID(response.UserID), } if err := result.Validate(); err != nil { return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err) } return result, nil } // BlockByEmail applies a block state to input.Email even when no user record // currently exists for that e-mail address. func (c *RESTClient) BlockByEmail(ctx context.Context, input ports.BlockUserByEmailInput) (ports.BlockUserResult, error) { if err := validateContext(ctx, "block by email"); err != nil { return ports.BlockUserResult{}, err } if err := input.Validate(); err != nil { return ports.BlockUserResult{}, fmt.Errorf("block by email: %w", err) } var response struct { Outcome ports.BlockUserOutcome `json:"outcome"` UserID string `json:"user_id,omitempty"` } if err := c.doJSON(ctx, "block by email", http.MethodPost, blockByEmailPath, map[string]string{ "email": input.Email.String(), "reason_code": input.ReasonCode.String(), }, &response, false); err != nil { return ports.BlockUserResult{}, err } result := ports.BlockUserResult{ Outcome: response.Outcome, UserID: common.UserID(response.UserID), } if err := result.Validate(); err != nil { return ports.BlockUserResult{}, fmt.Errorf("block by email: %w", err) } return result, nil } func (c *RESTClient) doJSON(ctx context.Context, operation string, method string, requestPath string, requestBody any, responseTarget any, retryRead bool) error { payload, statusCode, err := c.doRequest(ctx, operation, method, requestPath, requestBody, retryRead) if err != nil { return err } if statusCode != http.StatusOK { return fmt.Errorf("%s: unexpected HTTP status %d", operation, statusCode) } if err := decodeJSONPayload(payload, responseTarget); err != nil { return fmt.Errorf("%s: %w", operation, err) } return nil } func (c *RESTClient) doRequest(ctx context.Context, operation string, method string, requestPath string, requestBody any, retryRead bool) ([]byte, int, error) { bodyBytes, err := marshalOptionalRequestBody(requestBody) if err != nil { return nil, 0, fmt.Errorf("%s: %w", operation, err) } attempts := 1 if retryRead { attempts = 2 } var lastErr error for attempt := 0; attempt < attempts; attempt++ { attemptCtx, cancel := context.WithTimeout(ctx, c.requestTimeout) request, err := http.NewRequestWithContext(attemptCtx, method, c.baseURL+requestPath, bytes.NewReader(bodyBytes)) if err != nil { cancel() return nil, 0, fmt.Errorf("%s: build request: %w", operation, err) } if method == http.MethodPost { request.Header.Set("Content-Type", "application/json") } response, err := c.httpClient.Do(request) if err != nil { cancel() lastErr = fmt.Errorf("%s: %w", operation, err) if retryRead && attempt == 0 && ctx.Err() == nil { continue } return nil, 0, lastErr } payload, readErr := io.ReadAll(response.Body) closeErr := response.Body.Close() cancel() if readErr != nil { lastErr = fmt.Errorf("%s: read response body: %w", operation, readErr) if retryRead && attempt == 0 && ctx.Err() == nil { continue } return nil, 0, lastErr } if closeErr != nil { lastErr = fmt.Errorf("%s: close response body: %w", operation, closeErr) if retryRead && attempt == 0 && ctx.Err() == nil { continue } return nil, 0, lastErr } if retryRead && attempt == 0 && isRetriableUserServiceStatus(response.StatusCode) { lastErr = fmt.Errorf("%s: unexpected HTTP status %d", operation, response.StatusCode) continue } return payload, response.StatusCode, nil } return nil, 0, lastErr } func marshalOptionalRequestBody(value any) ([]byte, error) { if value == nil { return nil, nil } payload, err := json.Marshal(value) if err != nil { return nil, fmt.Errorf("marshal request body: %w", err) } return payload, nil } func decodeJSONPayload(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return fmt.Errorf("decode response body: %w", err) } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("decode response body: unexpected trailing JSON input") } return fmt.Errorf("decode response body: %w", err) } return nil } func isRetriableUserServiceStatus(statusCode int) bool { switch statusCode { case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: return true default: return false } } var _ ports.UserDirectory = (*RESTClient)(nil)