185 lines
5.3 KiB
Go
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)
|