feat: notification service
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
// Package userservice provides the trusted internal User Service HTTP client
|
||||
// used by Notification Service recipient enrichment.
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
getUserByIDPathSuffix = "/api/v1/internal/users/%s"
|
||||
subjectNotFoundErrorCode = "subject_not_found"
|
||||
)
|
||||
|
||||
// Config configures one HTTP-backed User Service enrichment client.
|
||||
type Config struct {
|
||||
// BaseURL stores the absolute base URL of the trusted internal User Service
|
||||
// HTTP API.
|
||||
BaseURL string
|
||||
|
||||
// RequestTimeout bounds one outbound lookup request.
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// Client resolves Notification Service recipients through the trusted
|
||||
// internal User Service HTTP API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
requestTimeout time.Duration
|
||||
httpClient *http.Client
|
||||
closeIdleConnections func()
|
||||
}
|
||||
|
||||
type getUserByIDResponse struct {
|
||||
User userView `json:"user"`
|
||||
}
|
||||
|
||||
type userView struct {
|
||||
Email string `json:"email"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
}
|
||||
|
||||
type errorEnvelope struct {
|
||||
Error *errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// NewClient constructs a User Service client that uses repository-standard
|
||||
// HTTP transport instrumentation through otelhttp.
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
transport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("new notification user service client: default transport is not *http.Transport")
|
||||
}
|
||||
|
||||
baseTransport := transport.Clone()
|
||||
|
||||
return newClient(
|
||||
cfg,
|
||||
&http.Client{Transport: otelhttp.NewTransport(baseTransport)},
|
||||
baseTransport.CloseIdleConnections,
|
||||
)
|
||||
}
|
||||
|
||||
func newClient(cfg Config, httpClient *http.Client, closeIdleConnections func()) (*Client, error) {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.BaseURL) == "":
|
||||
return nil, errors.New("new notification user service client: base URL must not be empty")
|
||||
case cfg.RequestTimeout <= 0:
|
||||
return nil, errors.New("new notification user service client: request timeout must be positive")
|
||||
case httpClient == nil:
|
||||
return nil, errors.New("new notification user service 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 notification user service client: parse base URL: %w", err)
|
||||
}
|
||||
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
|
||||
return nil, errors.New("new notification user service client: base URL must be absolute")
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: parsedBaseURL.String(),
|
||||
requestTimeout: cfg.RequestTimeout,
|
||||
httpClient: httpClient,
|
||||
closeIdleConnections: closeIdleConnections,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases idle HTTP connections owned by the client transport.
|
||||
func (client *Client) Close() error {
|
||||
if client == nil || client.closeIdleConnections == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
client.closeIdleConnections()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByID resolves the current user email and preferred language for the
|
||||
// supplied stable userID.
|
||||
func (client *Client) GetUserByID(ctx context.Context, userID string) (acceptintent.UserRecord, error) {
|
||||
if client == nil || client.httpClient == nil {
|
||||
return acceptintent.UserRecord{}, errors.New("lookup user by id: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return acceptintent.UserRecord{}, errors.New("lookup user by id: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return acceptintent.UserRecord{}, err
|
||||
}
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return acceptintent.UserRecord{}, errors.New("lookup user by id: user id must not be empty")
|
||||
}
|
||||
|
||||
payload, statusCode, err := client.doRequest(ctx, http.MethodGet, fmt.Sprintf(getUserByIDPathSuffix, url.PathEscape(userID)))
|
||||
if err != nil {
|
||||
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: %w", userID, err)
|
||||
}
|
||||
|
||||
switch statusCode {
|
||||
case http.StatusOK:
|
||||
var response getUserByIDResponse
|
||||
if err := decodeJSONPayload(payload, &response); err != nil {
|
||||
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: decode success response: %w", userID, err)
|
||||
}
|
||||
|
||||
record := acceptintent.UserRecord{
|
||||
Email: response.User.Email,
|
||||
PreferredLanguage: response.User.PreferredLanguage,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: invalid success response: %w", userID, err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
case http.StatusNotFound:
|
||||
errorCode, err := decodeErrorCode(payload)
|
||||
if err != nil {
|
||||
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: decode error response: %w", userID, err)
|
||||
}
|
||||
if errorCode == subjectNotFoundErrorCode {
|
||||
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: %w", userID, acceptintent.ErrRecipientNotFound)
|
||||
}
|
||||
|
||||
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: unexpected error code %q for status %d", userID, errorCode, statusCode)
|
||||
default:
|
||||
return acceptintent.UserRecord{}, fmt.Errorf("lookup user by id %q: unexpected HTTP status %d", userID, statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) doRequest(ctx context.Context, method string, requestPath string) ([]byte, int, error) {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, client.requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
request, err := http.NewRequestWithContext(attemptCtx, method, client.baseURL+requestPath, nil)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
return payload, response.StatusCode, nil
|
||||
}
|
||||
|
||||
func decodeErrorCode(payload []byte) (string, error) {
|
||||
var envelope errorEnvelope
|
||||
if err := decodeStrictJSONPayload(payload, &envelope); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if envelope.Error == nil {
|
||||
return "", errors.New("missing error object")
|
||||
}
|
||||
if strings.TrimSpace(envelope.Error.Code) == "" {
|
||||
return "", errors.New("missing error code")
|
||||
}
|
||||
|
||||
return envelope.Error.Code, nil
|
||||
}
|
||||
|
||||
func decodeJSONPayload(payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 _ acceptintent.UserDirectory = (*Client)(nil)
|
||||
@@ -0,0 +1,219 @@
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
cfg: Config{
|
||||
BaseURL: "http://127.0.0.1:8080",
|
||||
RequestTimeout: time.Second,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty base url",
|
||||
cfg: Config{
|
||||
RequestTimeout: time.Second,
|
||||
},
|
||||
wantErr: "base URL must not be empty",
|
||||
},
|
||||
{
|
||||
name: "relative base url",
|
||||
cfg: Config{
|
||||
BaseURL: "/relative",
|
||||
RequestTimeout: time.Second,
|
||||
},
|
||||
wantErr: "base URL must be absolute",
|
||||
},
|
||||
{
|
||||
name: "non positive timeout",
|
||||
cfg: Config{
|
||||
BaseURL: "http://127.0.0.1:8080",
|
||||
},
|
||||
wantErr: "request timeout must be positive",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := NewClient(tt.cfg)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, client.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientGetUserByID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var captured capturedRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
captured = captureRequest(t, r)
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"user": map[string]any{
|
||||
"user_id": "user-123",
|
||||
"email": "pilot@example.com",
|
||||
"preferred_language": "en-US",
|
||||
"time_zone": "Europe/Kaliningrad",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, 250*time.Millisecond)
|
||||
|
||||
record, err := client.GetUserByID(context.Background(), "user-123")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, acceptintent.UserRecord{
|
||||
Email: "pilot@example.com",
|
||||
PreferredLanguage: "en-US",
|
||||
}, record)
|
||||
require.Equal(t, capturedRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/internal/users/user-123",
|
||||
}, captured)
|
||||
})
|
||||
|
||||
t.Run("subject not found", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, http.StatusNotFound, map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "subject_not_found",
|
||||
"message": "subject not found",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, 250*time.Millisecond)
|
||||
|
||||
_, err := client.GetUserByID(context.Background(), "user-missing")
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, acceptintent.ErrRecipientNotFound)
|
||||
})
|
||||
|
||||
t.Run("invalid email is treated as dependency failure", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"user": map[string]any{
|
||||
"email": "bad@@example.com",
|
||||
"preferred_language": "en",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, 250*time.Millisecond)
|
||||
|
||||
_, err := client.GetUserByID(context.Background(), "user-123")
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, acceptintent.ErrRecipientNotFound)
|
||||
require.ErrorContains(t, err, "invalid success response")
|
||||
})
|
||||
|
||||
t.Run("timeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, 10*time.Millisecond)
|
||||
|
||||
_, err := client.GetUserByID(context.Background(), "user-123")
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, acceptintent.ErrRecipientNotFound)
|
||||
require.ErrorContains(t, err, "context deadline exceeded")
|
||||
})
|
||||
}
|
||||
|
||||
type capturedRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T, baseURL string, requestTimeout time.Duration) *Client {
|
||||
t.Helper()
|
||||
|
||||
client, err := newClient(
|
||||
Config{
|
||||
BaseURL: baseURL,
|
||||
RequestTimeout: requestTimeout,
|
||||
},
|
||||
&http.Client{Transport: http.DefaultTransport.(*http.Transport).Clone()},
|
||||
func() {},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func captureRequest(t *testing.T, request *http.Request) capturedRequest {
|
||||
t.Helper()
|
||||
|
||||
_, err := io.ReadAll(request.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, request.Body.Close())
|
||||
|
||||
return capturedRequest{
|
||||
Method: request.Method,
|
||||
Path: request.URL.Path,
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, writer http.ResponseWriter, statusCode int, payload any) {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(statusCode)
|
||||
_, err = writer.Write(body)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClientCloseIsNilSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var nilClient *Client
|
||||
require.NoError(t, nilClient.Close())
|
||||
}
|
||||
Reference in New Issue
Block a user