Files
galaxy-game/authsession/internal/adapters/mail/rest_client.go
T
2026-04-17 18:39:16 +02:00

185 lines
5.3 KiB
Go

// Package mail provides runtime mail-delivery adapters for the auth/session
// service.
package mail
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"galaxy/authsession/internal/ports"
)
const sendLoginCodePath = "/api/v1/internal/login-code-deliveries"
// Config configures one HTTP-based mail-delivery client.
type Config struct {
// BaseURL is the absolute base URL of the internal mail-service HTTP API.
BaseURL string
// RequestTimeout bounds each outbound mail-service request.
RequestTimeout time.Duration
}
// RESTClient implements ports.MailSender over the frozen internal REST mail
// contract.
type RESTClient struct {
baseURL string
requestTimeout time.Duration
httpClient *http.Client
}
// NewRESTClient constructs a REST-backed MailSender 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 mail service REST client: base URL must not be empty")
case cfg.RequestTimeout <= 0:
return nil, errors.New("new mail service REST client: request timeout must be positive")
case httpClient == nil:
return nil, errors.New("new mail 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 mail service REST client: parse base URL: %w", err)
}
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
return nil, errors.New("new mail 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
}
// SendLoginCode submits one delivery request to the internal mail service
// without retrying transport or upstream failures.
func (c *RESTClient) SendLoginCode(ctx context.Context, input ports.SendLoginCodeInput) (ports.SendLoginCodeResult, error) {
if err := validateRESTContext(ctx, "send login code"); err != nil {
return ports.SendLoginCodeResult{}, err
}
if err := input.Validate(); err != nil {
return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: %w", err)
}
payload, statusCode, err := c.doRequest(ctx, "send login code", input.IdempotencyKey, map[string]string{
"email": input.Email.String(),
"code": input.Code,
"locale": input.Locale,
})
if err != nil {
return ports.SendLoginCodeResult{}, err
}
if statusCode != http.StatusOK {
return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: unexpected HTTP status %d", statusCode)
}
var response struct {
Outcome ports.SendLoginCodeOutcome `json:"outcome"`
}
if err := decodeJSONPayload(payload, &response); err != nil {
return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: %w", err)
}
result := ports.SendLoginCodeResult{Outcome: response.Outcome}
if err := result.Validate(); err != nil {
return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: %w", err)
}
return result, nil
}
func (c *RESTClient) doRequest(ctx context.Context, operation string, idempotencyKey string, requestBody any) ([]byte, int, error) {
bodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, 0, fmt.Errorf("%s: marshal request body: %w", operation, err)
}
attemptCtx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
request, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.baseURL+sendLoginCodePath, bytes.NewReader(bodyBytes))
if err != nil {
return nil, 0, fmt.Errorf("%s: build request: %w", operation, err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", idempotencyKey)
response, err := c.httpClient.Do(request)
if err != nil {
return nil, 0, fmt.Errorf("%s: %w", operation, err)
}
defer response.Body.Close()
payload, err := io.ReadAll(response.Body)
if err != nil {
return nil, 0, fmt.Errorf("%s: read response body: %w", operation, err)
}
return payload, response.StatusCode, 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 validateRESTContext(ctx context.Context, operation string) error {
if ctx == nil {
return fmt.Errorf("%s: nil context", operation)
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
return nil
}
var _ ports.MailSender = (*RESTClient)(nil)