feat: authsession service

This commit is contained in:
Ilia Denisov
2026-04-08 16:23:07 +02:00
committed by GitHub
parent 28f04916af
commit 86a68ed9d0
174 changed files with 31732 additions and 112 deletions
@@ -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)