400 lines
12 KiB
Go
400 lines
12 KiB
Go
// 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 input.Email, creates a new
|
|
// user when registration is allowed, or reports a blocked outcome.
|
|
func (c *RESTClient) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) {
|
|
if err := validateContext(ctx, "ensure user by email"); err != nil {
|
|
return ports.EnsureUserResult{}, err
|
|
}
|
|
if err := input.Validate(); err != nil {
|
|
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
|
|
}
|
|
|
|
payload := struct {
|
|
Email string `json:"email"`
|
|
RegistrationContext *struct {
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
TimeZone string `json:"time_zone"`
|
|
} `json:"registration_context,omitempty"`
|
|
}{
|
|
Email: input.Email.String(),
|
|
}
|
|
if input.RegistrationContext != nil {
|
|
payload.RegistrationContext = &struct {
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
TimeZone string `json:"time_zone"`
|
|
}{
|
|
PreferredLanguage: input.RegistrationContext.PreferredLanguage,
|
|
TimeZone: input.RegistrationContext.TimeZone,
|
|
}
|
|
}
|
|
|
|
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, payload, &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)
|