feat(nsd-adapter): REST-клиент ИШ НРД + маршрутизация типов пакетов
- internal/nsdadapter/igw/client.go: REST-клиент ИШ (SendPackage, GetStatus, ListIncoming) с base64-JSON, ретраями на 5xx, 4xx без ретраев - internal/nsdadapter/router.go: маршрутизация MessageKind -> PackageType ЭДО (#M2MTR, #M2MTD, #M2MER, SUBBR/SUBER/SUB16, Assets_investment) - internal/nsdadapter/sender.go: реализация m2mcore.NSDSender (Send/SendDecision) через REST ИШ, сериализация Request/Decision в windows-1251 - internal/nsdadapter/config.go: профили guest/test3/prod × gost/rsa (URL ИШ, канал, контейнер ключа, retry) - internal/nsdadapter/onyx/onyx.go: скелет резервного канала WS ONYX (ждёт PR-6 crypto-service для подписи) - cmd/nsd-adapter/main.go: HTTP /healthz + фоновый поллер входящих по типам ЭДО; idle-режим без BJ_NSD_PROFILE make ci зелёный. Без внешних Go-зависимостей. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package igw_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||
)
|
||||
|
||||
func TestSendPackageHappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/package/TEST3/file" {
|
||||
t.Errorf("неожиданный путь %q", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"package_id": "pkg-123"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
|
||||
id, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("<xml/>"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "pkg-123" {
|
||||
t.Errorf("package_id = %q, ожидалось %q", id, "pkg-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPackageRetryOn500(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
if calls < 2 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"package_id": "pkg-retry"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := igw.NewClient(srv.URL, igw.WithRetry(3, time.Millisecond))
|
||||
id, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("x"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "pkg-retry" {
|
||||
t.Errorf("ожидалось pkg-retry, получено %q", id)
|
||||
}
|
||||
if calls < 2 {
|
||||
t.Errorf("ожидалось хотя бы 2 попытки, получено %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPackage4xxNoRetry(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"bad"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := igw.NewClient(srv.URL, igw.WithRetry(3, time.Millisecond))
|
||||
_, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("x"))
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка на 400")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("4xx не должен ретраиться, попыток = %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/package/status/pkg-1" {
|
||||
t.Errorf("неожиданный путь %q", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"package_id":"pkg-1","state":"delivered","updated_at":"2026-03-02T14:30:00Z"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
|
||||
st, err := c.GetStatus(context.Background(), "pkg-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.State != "delivered" {
|
||||
t.Errorf("state = %q, ожидалось delivered", st.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListIncoming(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !contains(r.URL.RawQuery, "channel=TEST3") {
|
||||
t.Errorf("в query нет channel: %s", r.URL.RawQuery)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"items":[{"package_id":"p1","package_type":"#M2MTD","channel":"TEST3","received_at":"2026-03-02T14:00:00Z","body":""}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
|
||||
pkgs, err := c.ListIncoming(context.Background(), "TEST3", time.Now().Add(-time.Hour), "#M2MTD")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pkgs) != 1 || pkgs[0].PackageType != "#M2MTD" {
|
||||
t.Errorf("неожиданный результат: %+v", pkgs)
|
||||
}
|
||||
body, err := pkgs[0].DecodeBody()
|
||||
if err != nil {
|
||||
t.Errorf("DecodeBody: %v", err)
|
||||
}
|
||||
if body != nil {
|
||||
t.Errorf("ожидалось пустое тело")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (indexOf(s, substr) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, substr string) int {
|
||||
for i := 0; i+len(substr) <= len(s); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
Reference in New Issue
Block a user