feat: authsession service
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
// 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", map[string]string{
|
||||
"email": input.Email.String(),
|
||||
"code": input.Code,
|
||||
})
|
||||
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, 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")
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user