feat: authsession service
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
// 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)
|
||||
Reference in New Issue
Block a user