Files
portal-common/portal/client.go

236 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package portal — общий HTTP-клиент portal-сервиса для cross-service
// нужд: directory с кэшем+stale fallback'ом, notification fan-out,
// deactivate user.
//
// Раньше каждый сервис делал свой клон (booking/notifications,
// candidates/directory+deactivate, meet/notifications, leaders-reports/
// notifications). Теперь — один источник.
package portal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// User — DTO из /api/internal/users (см. portal handler.InternalUserDTO).
// Не все поля нужны каждому клиенту, но дублируем целиком, чтобы был
// единый shape (caller игнорирует лишнее).
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
MiddleName string `json:"middle_name"`
FullName string `json:"full_name"`
IsActive bool `json:"is_active"`
DepartmentID *string `json:"department_id"`
DepartmentName *string `json:"department_name"`
PositionID *string `json:"position_id"`
PositionName *string `json:"position_name"`
WorkGeoCode *string `json:"work_geo_code"`
}
type Client struct {
baseURL string
apiKey string
hc *http.Client
// Кэш directory. 5-мин TTL, при ошибке возвращаем stale — temporary
// portal outage не должен валить cross-service flow.
mu sync.Mutex
cache []User
cachedAt time.Time
cacheTTL time.Duration
}
// New строит клиент с дефолтами (timeout=10s, TTL=5m). Если baseURL или
// apiKey пустые — клиент Disabled, все методы вернут ошибку.
func New(baseURL, apiKey string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
hc: &http.Client{Timeout: 10 * time.Second},
cacheTTL: 5 * time.Minute,
}
}
// Enabled — true если конфигурация полная. Caller проверяет перед
// вызовом, чтобы решить «пытаться или skip».
func (c *Client) Enabled() bool {
return c != nil && c.baseURL != "" && c.apiKey != ""
}
// SetCacheTTL — переопределить TTL directory-кэша (для тестов или
// сервисов с особыми требованиями к свежести).
func (c *Client) SetCacheTTL(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.cacheTTL = d
}
// ListDirectory возвращает справочник юзеров (с кэшем). При ошибке сети —
// stale (если есть), иначе propagated error. Сетевой 5xx ретраится один
// раз (200ms backoff).
func (c *Client) ListDirectory(ctx context.Context) ([]User, error) {
if !c.Enabled() {
return nil, errors.New("portal client disabled")
}
c.mu.Lock()
if time.Since(c.cachedAt) < c.cacheTTL && c.cache != nil {
out := c.cache
c.mu.Unlock()
return out, nil
}
c.mu.Unlock()
users, err := c.fetchDirectory(ctx)
if err != nil {
c.mu.Lock()
stale := c.cache
c.mu.Unlock()
if stale != nil {
return stale, fmt.Errorf("fetch directory (using stale cache): %w", err)
}
return nil, err
}
c.mu.Lock()
c.cache = users
c.cachedAt = time.Now()
c.mu.Unlock()
return users, nil
}
// CreateNotificationRequest — DTO для /api/internal/notifications.
// Channels пока не enforce'ится сервером — задел на будущий fan-out.
type CreateNotificationRequest struct {
UserID string `json:"user_id"`
Title string `json:"title"`
Message string `json:"message"`
Type string `json:"type,omitempty"`
Link string `json:"link,omitempty"`
Channels []string `json:"channels,omitempty"`
}
func (c *Client) CreateNotification(ctx context.Context, req CreateNotificationRequest) error {
if !c.Enabled() {
return errors.New("portal client disabled")
}
return c.postJSON(ctx, "/api/internal/notifications", req)
}
// DeactivateUser помечает юзера is_active=false + invalidate'ит токены.
// 404 от portal'а — already-gone, для caller'а это успех.
func (c *Client) DeactivateUser(ctx context.Context, userID string) error {
if !c.Enabled() {
return errors.New("portal client disabled")
}
if userID == "" {
return errors.New("user id required")
}
url := c.baseURL + "/api/users/" + userID + "/deactivate"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("X-Internal-Key", c.apiKey)
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("portal returned %d: %s", resp.StatusCode, string(body))
}
return nil
}
// postJSON — общий helper для POST /api/* с JSON-телом и X-Internal-Key.
func (c *Client) postJSON(ctx context.Context, path string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
url := c.baseURL + path
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("new request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-Internal-Key", c.apiKey)
resp, err := c.hc.Do(httpReq)
if err != nil {
return fmt.Errorf("post: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("portal %s returned %d: %s", path, resp.StatusCode, string(respBody))
}
return nil
}
// fetchDirectory — GET /api/internal/users с retry'ем на 5xx и парсингом
// обоих форматов ответа (массив или {users: []}).
func (c *Client) fetchDirectory(ctx context.Context) ([]User, error) {
url := c.baseURL + "/api/internal/users"
var resp *http.Response
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
if attempt > 0 {
time.Sleep(200 * time.Millisecond)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("X-Internal-Key", c.apiKey)
resp, lastErr = c.hc.Do(req)
if lastErr != nil {
continue
}
if resp.StatusCode >= 500 && resp.StatusCode < 600 {
_ = resp.Body.Close()
lastErr = fmt.Errorf("portal 5xx: %d", resp.StatusCode)
continue
}
break
}
if lastErr != nil && resp == nil {
return nil, fmt.Errorf("get directory: %w", lastErr)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("portal returned %d: %s", resp.StatusCode, string(body))
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
var asArr []User
if err := json.Unmarshal(raw, &asArr); err == nil {
return asArr, nil
}
var asObj struct {
Users []User `json:"users"`
}
if err := json.Unmarshal(raw, &asObj); err != nil {
return nil, fmt.Errorf("parse directory: %w", err)
}
return asObj.Users, nil
}