package restapi import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" ) const ( authServiceSendEmailCodePath = "/api/v1/public/auth/send-email-code" authServiceConfirmEmailCodePath = "/api/v1/public/auth/confirm-email-code" ) // HTTPAuthServiceClient implements AuthServiceClient over the Auth / Session // Service public HTTP API using strict JSON request and response decoding. type HTTPAuthServiceClient struct { baseURL string httpClient *http.Client } type authServiceErrorEnvelope struct { Error *authServiceErrorBody `json:"error"` } type authServiceErrorBody struct { Code string `json:"code"` Message string `json:"message"` } // NewHTTPAuthServiceClient constructs an AuthServiceClient that delegates the // gateway public-auth routes to the Auth / Session Service public HTTP API at // baseURL. The resulting client relies only on the caller-provided context for // cancellation and timeout control. func NewHTTPAuthServiceClient(baseURL string) (*HTTPAuthServiceClient, error) { transport, ok := http.DefaultTransport.(*http.Transport) if !ok { return nil, errors.New("new auth service HTTP client: default transport is not *http.Transport") } return newHTTPAuthServiceClient(baseURL, &http.Client{ Transport: transport.Clone(), }) } func newHTTPAuthServiceClient(baseURL string, httpClient *http.Client) (*HTTPAuthServiceClient, error) { if httpClient == nil { return nil, errors.New("new auth service HTTP client: http client must not be nil") } trimmedBaseURL := strings.TrimSpace(baseURL) if trimmedBaseURL == "" { return nil, errors.New("new auth service HTTP client: base URL must not be empty") } parsedBaseURL, err := url.Parse(strings.TrimRight(trimmedBaseURL, "/")) if err != nil { return nil, fmt.Errorf("new auth service HTTP client: parse base URL: %w", err) } if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" { return nil, errors.New("new auth service HTTP client: base URL must be absolute") } return &HTTPAuthServiceClient{ baseURL: parsedBaseURL.String(), httpClient: httpClient, }, nil } // Close releases idle HTTP connections owned by the client transport. func (c *HTTPAuthServiceClient) 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 } // SendEmailCode delegates the public send-email-code route to the configured // Auth / Session Service public HTTP API. func (c *HTTPAuthServiceClient) SendEmailCode(ctx context.Context, input SendEmailCodeInput) (SendEmailCodeResult, error) { payload, statusCode, err := c.doJSONRequest(ctx, authServiceSendEmailCodePath, input, map[string]string{ "Accept-Language": resolvePreferredLanguage(input.PreferredLanguage), }) if err != nil { return SendEmailCodeResult{}, fmt.Errorf("send email code via auth service: %w", err) } switch { case statusCode == http.StatusOK: var result SendEmailCodeResult if err := decodeStrictJSONPayload(payload, &result); err != nil { return SendEmailCodeResult{}, fmt.Errorf("send email code via auth service: decode success response: %w", err) } if err := validateSendEmailCodeResult(&result); err != nil { return SendEmailCodeResult{}, fmt.Errorf("send email code via auth service: %w", err) } return result, nil case statusCode >= 400 && statusCode <= 599: authErr, err := decodeAuthServiceError(statusCode, payload) if err != nil { return SendEmailCodeResult{}, fmt.Errorf("send email code via auth service: %w", err) } return SendEmailCodeResult{}, authErr default: return SendEmailCodeResult{}, fmt.Errorf("send email code via auth service: unexpected HTTP status %d", statusCode) } } // ConfirmEmailCode delegates the public confirm-email-code route to the // configured Auth / Session Service public HTTP API. func (c *HTTPAuthServiceClient) ConfirmEmailCode(ctx context.Context, input ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) { payload, statusCode, err := c.doJSONRequest(ctx, authServiceConfirmEmailCodePath, input, nil) if err != nil { return ConfirmEmailCodeResult{}, fmt.Errorf("confirm email code via auth service: %w", err) } switch { case statusCode == http.StatusOK: var result ConfirmEmailCodeResult if err := decodeStrictJSONPayload(payload, &result); err != nil { return ConfirmEmailCodeResult{}, fmt.Errorf("confirm email code via auth service: decode success response: %w", err) } if err := validateConfirmEmailCodeResult(&result); err != nil { return ConfirmEmailCodeResult{}, fmt.Errorf("confirm email code via auth service: %w", err) } return result, nil case statusCode >= 400 && statusCode <= 599: authErr, err := decodeAuthServiceError(statusCode, payload) if err != nil { return ConfirmEmailCodeResult{}, fmt.Errorf("confirm email code via auth service: %w", err) } return ConfirmEmailCodeResult{}, authErr default: return ConfirmEmailCodeResult{}, fmt.Errorf("confirm email code via auth service: unexpected HTTP status %d", statusCode) } } func (c *HTTPAuthServiceClient) doJSONRequest(ctx context.Context, path string, requestBody any, headers map[string]string) ([]byte, int, error) { if c == nil || c.httpClient == nil { return nil, 0, errors.New("nil client") } if ctx == nil { return nil, 0, errors.New("nil context") } if err := ctx.Err(); err != nil { return nil, 0, err } payload, err := json.Marshal(requestBody) if err != nil { return nil, 0, fmt.Errorf("marshal request body: %w", err) } request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(payload)) if err != nil { return nil, 0, fmt.Errorf("build request: %w", err) } request.Header.Set("Content-Type", "application/json") for key, value := range headers { if strings.TrimSpace(value) == "" { continue } request.Header.Set(key, value) } response, err := c.httpClient.Do(request) if err != nil { return nil, 0, err } defer response.Body.Close() responsePayload, err := io.ReadAll(response.Body) if err != nil { return nil, 0, fmt.Errorf("read response body: %w", err) } return responsePayload, response.StatusCode, nil } func decodeAuthServiceError(statusCode int, payload []byte) (*AuthServiceError, error) { var envelope authServiceErrorEnvelope if err := decodeStrictJSONPayload(payload, &envelope); err != nil { return nil, fmt.Errorf("decode error response: %w", err) } if envelope.Error == nil { return nil, errors.New("decode error response: missing error object") } return &AuthServiceError{ StatusCode: statusCode, Code: envelope.Error.Code, Message: envelope.Error.Message, }, nil } func decodeStrictJSONPayload(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("unexpected trailing JSON input") } return err } return nil } var _ AuthServiceClient = (*HTTPAuthServiceClient)(nil)