// Package igw — REST-клиент Интеграционного шлюза (ИШ) НРД. // Тело пакета передаётся base64 в JSON; ИШ сам подписывает и // упаковывает в ZIP-пакет ЭДО по правилам НРД. package igw import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) // Client — REST-клиент ИШ НРД. type Client struct { baseURL string httpClient *http.Client retryMax int retryWait time.Duration } // Option настраивает Client. type Option func(*Client) // WithHTTPClient заменяет стандартный http.Client (для тестов и // фиксации таймаутов). func WithHTTPClient(c *http.Client) Option { return func(cl *Client) { cl.httpClient = c } } // WithRetry задаёт количество ретраев и базовое ожидание (линейный backoff). func WithRetry(max int, wait time.Duration) Option { return func(cl *Client) { cl.retryMax = max; cl.retryWait = wait } } // NewClient собирает клиента к ИШ по URL. func NewClient(baseURL string, opts ...Option) *Client { c := &Client{ baseURL: strings.TrimRight(baseURL, "/"), httpClient: &http.Client{Timeout: 30 * time.Second}, retryMax: 3, retryWait: time.Second, } for _, o := range opts { o(c) } return c } // SendPackage отправляет пакет в указанный канал ЭДО. Возвращает // идентификатор пакета, присвоенный ИШ. func (c *Client) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) { if channel == "" { return "", errors.New("igw: channel пустой") } if packageType == "" { return "", errors.New("igw: packageType пустой") } payload := struct { PackageType string `json:"package_type"` Body string `json:"body"` }{ PackageType: packageType, Body: base64.StdEncoding.EncodeToString(body), } raw, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("igw: marshal payload: %w", err) } path := fmt.Sprintf("/api/package/%s/file", url.PathEscape(channel)) resp, err := c.doRetry(ctx, http.MethodPost, path, bytes.NewReader(raw), "application/json") if err != nil { return "", err } defer resp.Body.Close() var out struct { PackageID string `json:"package_id"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", fmt.Errorf("igw: decode SendPackage response: %w", err) } if out.PackageID == "" { return "", errors.New("igw: пустой package_id в ответе ИШ") } return out.PackageID, nil } // Status — состояние пакета у ИШ. type Status struct { PackageID string `json:"package_id"` State string `json:"state"` UpdatedAt time.Time `json:"updated_at"` ErrorCode string `json:"error_code,omitempty"` ErrorText string `json:"error_text,omitempty"` } // GetStatus возвращает текущее состояние пакета по идентификатору. func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error) { if packageID == "" { return Status{}, errors.New("igw: packageID пустой") } path := "/api/package/status/" + url.PathEscape(packageID) resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "") if err != nil { return Status{}, err } defer resp.Body.Close() var s Status if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { return Status{}, fmt.Errorf("igw: decode Status: %w", err) } return s, nil } // Package — описание входящего пакета. type Package struct { PackageID string `json:"package_id"` PackageType string `json:"package_type"` Channel string `json:"channel"` ReceivedAt time.Time `json:"received_at"` Body string `json:"body,omitempty"` // base64 } // DecodeBody возвращает декодированное содержимое пакета. func (p Package) DecodeBody() ([]byte, error) { if p.Body == "" { return nil, nil } return base64.StdEncoding.DecodeString(p.Body) } // ListIncoming возвращает список входящих пакетов по фильтрам. func (c *Client) ListIncoming(ctx context.Context, channel string, since time.Time, packageType string) ([]Package, error) { q := url.Values{} if channel != "" { q.Set("channel", channel) } if !since.IsZero() { q.Set("date", since.UTC().Format(time.RFC3339)) } if packageType != "" { q.Set("type", packageType) } path := "/api/package?" + q.Encode() resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "") if err != nil { return nil, err } defer resp.Body.Close() var out struct { Items []Package `json:"items"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, fmt.Errorf("igw: decode ListIncoming: %w", err) } return out.Items, nil } // doRetry выполняет HTTP-запрос с ретраями на сетевые ошибки и 5xx. func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) { var lastErr error var bodyBytes []byte if body != nil { b, err := io.ReadAll(body) if err != nil { return nil, err } bodyBytes = b } for attempt := 0; attempt <= c.retryMax; attempt++ { req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(bodyBytes)) if err != nil { return nil, err } if contentType != "" { req.Header.Set("Content-Type", contentType) } req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) switch { case err != nil: lastErr = err case resp.StatusCode >= 500: _ = resp.Body.Close() lastErr = fmt.Errorf("igw: HTTP %d", resp.StatusCode) case resp.StatusCode >= 400: defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("igw: HTTP %d: %s", resp.StatusCode, string(b)) default: return resp, nil } if attempt < c.retryMax { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(c.retryWait * time.Duration(attempt+1)): } } } return nil, fmt.Errorf("igw: исчерпаны ретраи: %w", lastErr) }