de41aea00c
Полный клиент Интеграционного шлюза НРД в internal/nsdadapter/igw/:
client.go — REST endpoint'ы по свежей спецификации НРД:
- POST /api/package/{channel}/file — отправка ZIP (Type=archive, File=base64)
возвращает id пакета (поддерживаются варианты id|package_id|ID)
- GET /api/package/status/{id} — статус NEW|SENT|ERROR (с error-полем)
- GET /api/package?channel=&type=M2MTD|M2MER&date=&id=&count=&excludeErrors=
— список входящих от НРД, с files[] и signs[] (ИШ сам проверяет ЭП и
выдаёт VALID|INVALID)
- GET /api/package/{id} — скачать ZIP (raw или base64-в-JSON, авто-детект
по сигнатуре PK\x03\x04)
- Ретраи только на 5xx/сетевые ошибки (4xx — сразу ошибка)
- HTTP-клиент через options, кастомный таймаут, ретраи
pack.go — упаковщик/распаковщик ZIP по разделу 2.3 инструкции:
- PackRequest(req, docName) — M2MTransferRequest→ZIP с config.xml
- PackXML(xml, docName, packageType) — для эталонных сообщений
- UnpackPackage(zip) → {DocXML, WinfXML, Signature, Filenames}
- ParseDecision / ParseResponse через nsdxml.Unmarshal
Покрыто тестами (10/10 PASS):
- send happy path с проверкой формата JSON-body
- retry на 5xx, без ретраев на 4xx
- GetStatus с числовым id
- ListIncoming как массив (новый формат) и как {items:[]} (старый)
- GetPackage raw ZIP + GetPackage с base64-в-JSON
- упаковка/распаковка: 2 файла в ZIP, имена, содержимое config.xml
- распаковка с .sgn и winf.xml
cmd/bj-server/main.go — NSD-poller адаптирован под новый API
(client.ListIncoming(ctx, ListFilter{}) вместо позиционных параметров;
поля Package.ID/Name/Type/State вместо PackageID/PackageType).
Скачана и положена в DOC/ свежая спецификация (798 KB, 15 стр):
DOC/instr-ish-rest-api.pdf — это исходный документ для нашей реализации.
REPORT.md обновлён:
- общая готовность 65% → 70%
- готовность к роботу 80% → 85%
- добавлен раздел про REST-клиент ИШ
- блокер #6 — отсутствие «Руководства по установке ИШ»
380 lines
13 KiB
Go
380 lines
13 KiB
Go
// Package igw — REST-клиент Интеграционного шлюза (ИШ) НРД.
|
||
// Документ-источник: DOC/instr-ish-rest-api.pdf (НРД, 2026).
|
||
//
|
||
// ИШ — серверное ПО НРД, которое:
|
||
// - принимает от нас сырой XML/ZIP M2M-документа;
|
||
// - сам подписывает его сертификатом УЦ МБ (ключ настроен в ИШ);
|
||
// - формирует пакет ЭДО по Правилам ЭДО НРД;
|
||
// - отправляет в НРД через Web-сервис ONYX;
|
||
// - принимает входящие пакеты M2MTD/M2MER от НРД;
|
||
// - проверяет подпись НРД (поле signs.status = VALID/INVALID);
|
||
// - выдаёт всё это клиенту через REST API.
|
||
//
|
||
// REST-эндпоинты (все по 200/JSON):
|
||
// POST /api/package/{channel}/file — отправить ZIP, вернёт id
|
||
// GET /api/package/status/{id} — статус: NEW | SENT | ERROR
|
||
// GET /api/package?channel=&type=... — список входящих
|
||
// GET /api/package/{id} — тело пакета (ZIP в base64)
|
||
package igw
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"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
|
||
}
|
||
|
||
// --------------- POST /api/package/{channel}/file ---------------
|
||
|
||
// sendBody — тело запроса на отправку пакета. По спецификации НРД
|
||
// (instr-ish-rest-api.pdf, раздел 2.5.1) поля Type/File.
|
||
type sendBody struct {
|
||
Type string `json:"Type"`
|
||
File string `json:"File"`
|
||
}
|
||
|
||
// sendResponse — ответ ИШ на отправку. Спецификация: 200 + JSON с ID.
|
||
// В новом документе НРД сам формат JSON не зафиксирован детально, поэтому
|
||
// принимаем три популярные формы: {id:..}, {package_id:..}, {ID:..}.
|
||
type sendResponse struct {
|
||
ID json.Number `json:"id,omitempty"`
|
||
PackageID json.Number `json:"package_id,omitempty"`
|
||
IDAlt json.Number `json:"ID,omitempty"`
|
||
}
|
||
|
||
// SendPackage отправляет ZIP-архив (M2MTransferRequest.xml + config.xml)
|
||
// в указанный канал ЭДО ИШ. Сигнатура совместима с предыдущей версией —
|
||
// packageType остался параметром для backward-compat, но в новом API
|
||
// он внутри ZIP'а (в config.xml/<package>), не в HTTP-теле.
|
||
//
|
||
// Возвращает идентификатор пакета (как строку — может быть числом или
|
||
// UUID, зависит от версии ИШ).
|
||
func (c *Client) SendPackage(ctx context.Context, channel, packageType string, zipBody []byte) (string, error) {
|
||
if channel == "" {
|
||
return "", errors.New("igw: channel пустой")
|
||
}
|
||
_ = packageType // не используется в новом API, кладётся внутрь ZIP/config.xml
|
||
if len(zipBody) == 0 {
|
||
return "", errors.New("igw: zipBody пустой")
|
||
}
|
||
payload := sendBody{
|
||
Type: "archive",
|
||
File: base64.StdEncoding.EncodeToString(zipBody),
|
||
}
|
||
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 sendResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||
return "", fmt.Errorf("igw: decode SendPackage response: %w", err)
|
||
}
|
||
for _, v := range []json.Number{out.ID, out.PackageID, out.IDAlt} {
|
||
if s := string(v); s != "" {
|
||
return s, nil
|
||
}
|
||
}
|
||
return "", errors.New("igw: пустой id в ответе ИШ")
|
||
}
|
||
|
||
// --------------- GET /api/package/status/{id} ---------------
|
||
|
||
// Status — состояние отправленного пакета (раздел 2.5.2 инструкции).
|
||
// Status может быть NEW (новый), SENT (отправлен), ERROR (ошибка).
|
||
type Status struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Status string `json:"status"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// Status-константы для удобства.
|
||
const (
|
||
StatusNew = "NEW"
|
||
StatusSent = "SENT"
|
||
StatusError = "ERROR"
|
||
)
|
||
|
||
// 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()
|
||
// Дублируем альтернативные имена полей (id|ID, status|state) — на
|
||
// случай различий между версиями ИШ.
|
||
var raw map[string]json.RawMessage
|
||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||
return Status{}, fmt.Errorf("igw: decode Status: %w", err)
|
||
}
|
||
s := Status{}
|
||
pickStr(raw, []string{"id", "ID", "package_id"}, &s.ID)
|
||
pickStr(raw, []string{"name"}, &s.Name)
|
||
pickStr(raw, []string{"status", "state"}, &s.Status)
|
||
pickStr(raw, []string{"error", "error_text", "error_code"}, &s.Error)
|
||
return s, nil
|
||
}
|
||
|
||
// --------------- GET /api/package?channel=&type=... ---------------
|
||
|
||
// ListFilter — параметры фильтрации входящих пакетов (раздел 2.6).
|
||
type ListFilter struct {
|
||
Channel string // обязательный — код канала ЭДО
|
||
Date time.Time // опц., YYYY-MM-DD
|
||
SinceID int // опц., скип до этого id
|
||
Count int // опц., лимит
|
||
Type string // опц., "M2MTD" | "M2MER" (без #)
|
||
ExcludeErrors bool // опц., исключать пакеты с ошибкой
|
||
}
|
||
|
||
// Sign — подпись пакета. ИШ сам проверяет и выдаёт результат: VALID/INVALID.
|
||
type Sign struct {
|
||
Serial string `json:"serial"`
|
||
Subject string `json:"subject"`
|
||
Description string `json:"description"`
|
||
Status string `json:"status"` // "VALID" | "INVALID"
|
||
}
|
||
|
||
// File — файл внутри пакета.
|
||
type File struct {
|
||
ID int `json:"id"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
// Package — описание входящего пакета по списку (раздел 2.6).
|
||
type Package struct {
|
||
Channel string `json:"channel"`
|
||
ID int `json:"id"`
|
||
Name string `json:"name"`
|
||
Type string `json:"type"` // "M2MTD" | "M2MER"
|
||
State string `json:"state"` // "RECEIVED" | "ERROR" | "DELETED"
|
||
Files []File `json:"files,omitempty"`
|
||
Signs []Sign `json:"signs,omitempty"`
|
||
}
|
||
|
||
// ListIncoming возвращает список входящих пакетов от НРД по фильтрам.
|
||
// Если filter.Type не задан — возвращает оба типа M2MTD + M2MER.
|
||
func (c *Client) ListIncoming(ctx context.Context, filter ListFilter) ([]Package, error) {
|
||
if filter.Channel == "" {
|
||
return nil, errors.New("igw: ListFilter.Channel обязателен")
|
||
}
|
||
q := url.Values{}
|
||
q.Set("channel", filter.Channel)
|
||
if !filter.Date.IsZero() {
|
||
q.Set("date", filter.Date.UTC().Format("2006-01-02"))
|
||
}
|
||
if filter.SinceID > 0 {
|
||
q.Set("id", strconv.Itoa(filter.SinceID))
|
||
}
|
||
if filter.Count > 0 {
|
||
q.Set("count", strconv.Itoa(filter.Count))
|
||
}
|
||
if filter.Type != "" {
|
||
q.Set("type", filter.Type)
|
||
}
|
||
if filter.ExcludeErrors {
|
||
q.Set("excludeErrors", "true")
|
||
}
|
||
path := "/api/package?" + q.Encode()
|
||
resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// ИШ может вернуть либо массив, либо {items: [...]}. Поддержим оба.
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("igw: read ListIncoming: %w", err)
|
||
}
|
||
body = bytes.TrimSpace(body)
|
||
if len(body) == 0 {
|
||
return nil, nil
|
||
}
|
||
if body[0] == '[' {
|
||
var arr []Package
|
||
if err := json.Unmarshal(body, &arr); err != nil {
|
||
return nil, fmt.Errorf("igw: decode ListIncoming (array): %w", err)
|
||
}
|
||
return arr, nil
|
||
}
|
||
var wrap struct {
|
||
Items []Package `json:"items"`
|
||
}
|
||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||
return nil, fmt.Errorf("igw: decode ListIncoming (object): %w", err)
|
||
}
|
||
return wrap.Items, nil
|
||
}
|
||
|
||
// --------------- GET /api/package/{id} ---------------
|
||
|
||
// GetPackage возвращает содержимое пакета по ID — ZIP-архив с файлами
|
||
// документа и отсоединёнными подписями. По спецификации (раздел 2.6.2)
|
||
// ИШ отвечает 200 + body = base64-encoded ZIP. Метод декодирует base64
|
||
// и возвращает сырые ZIP-байты.
|
||
func (c *Client) GetPackage(ctx context.Context, packageID int) ([]byte, error) {
|
||
if packageID <= 0 {
|
||
return nil, errors.New("igw: packageID должен быть > 0")
|
||
}
|
||
path := "/api/package/" + strconv.Itoa(packageID)
|
||
resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("igw: read GetPackage: %w", err)
|
||
}
|
||
// ИШ возвращает либо чистый ZIP (Content-Type: application/zip),
|
||
// либо JSON с base64. Проверим по сигнатуре ZIP (PK\x03\x04).
|
||
if len(body) >= 4 && body[0] == 'P' && body[1] == 'K' &&
|
||
body[2] == 0x03 && body[3] == 0x04 {
|
||
return body, nil
|
||
}
|
||
// Иначе пробуем base64 (с JSON-обёрткой или без).
|
||
stripped := bytes.TrimSpace(body)
|
||
if len(stripped) > 1 && stripped[0] == '"' && stripped[len(stripped)-1] == '"' {
|
||
stripped = stripped[1 : len(stripped)-1]
|
||
}
|
||
// JSON-объект {"file":"..."} или {"body":"..."}
|
||
if len(stripped) > 0 && stripped[0] == '{' {
|
||
var obj map[string]string
|
||
if err := json.Unmarshal(stripped, &obj); err == nil {
|
||
for _, k := range []string{"file", "File", "body", "Body"} {
|
||
if v, ok := obj[k]; ok {
|
||
return base64.StdEncoding.DecodeString(v)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
decoded, err := base64.StdEncoding.DecodeString(string(stripped))
|
||
if err == nil && len(decoded) >= 4 && decoded[0] == 'P' && decoded[1] == 'K' {
|
||
return decoded, nil
|
||
}
|
||
return body, nil
|
||
}
|
||
|
||
// --------------- общая HTTP-логика ---------------
|
||
|
||
// 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)
|
||
}
|
||
|
||
// pickStr заполняет dest первым непустым значением из raw по списку ключей.
|
||
func pickStr(raw map[string]json.RawMessage, keys []string, dest *string) {
|
||
for _, k := range keys {
|
||
if v, ok := raw[k]; ok {
|
||
var s string
|
||
if err := json.Unmarshal(v, &s); err == nil && s != "" {
|
||
*dest = s
|
||
return
|
||
}
|
||
// Может быть числом — превращаем в строку.
|
||
var n json.Number
|
||
if err := json.Unmarshal(v, &n); err == nil && string(n) != "" {
|
||
*dest = string(n)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|