feat(igw): REST-клиент ИШ НРД по DOC/instr-ish-rest-api.pdf + упаковщик ZIP
Полный клиент Интеграционного шлюза НРД в 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 — отсутствие «Руководства по установке ИШ»
This commit is contained in:
@@ -1,6 +1,20 @@
|
||||
// Package igw — REST-клиент Интеграционного шлюза (ИШ) НРД.
|
||||
// Тело пакета передаётся base64 в JSON; ИШ сам подписывает и
|
||||
// упаковывает в ZIP-пакет ЭДО по правилам НРД.
|
||||
// Документ-источник: 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 (
|
||||
@@ -13,6 +27,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -53,21 +68,42 @@ func NewClient(baseURL string, opts ...Option) *Client {
|
||||
return c
|
||||
}
|
||||
|
||||
// SendPackage отправляет пакет в указанный канал ЭДО. Возвращает
|
||||
// идентификатор пакета, присвоенный ИШ.
|
||||
func (c *Client) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) {
|
||||
// --------------- 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 пустой")
|
||||
}
|
||||
if packageType == "" {
|
||||
return "", errors.New("igw: packageType пустой")
|
||||
_ = packageType // не используется в новом API, кладётся внутрь ZIP/config.xml
|
||||
if len(zipBody) == 0 {
|
||||
return "", errors.New("igw: zipBody пустой")
|
||||
}
|
||||
payload := struct {
|
||||
PackageType string `json:"package_type"`
|
||||
Body string `json:"body"`
|
||||
}{
|
||||
PackageType: packageType,
|
||||
Body: base64.StdEncoding.EncodeToString(body),
|
||||
payload := sendBody{
|
||||
Type: "archive",
|
||||
File: base64.StdEncoding.EncodeToString(zipBody),
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -80,27 +116,36 @@ func (c *Client) SendPackage(ctx context.Context, channel, packageType string, b
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out struct {
|
||||
PackageID string `json:"package_id"`
|
||||
}
|
||||
var out sendResponse
|
||||
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 в ответе ИШ")
|
||||
for _, v := range []json.Number{out.ID, out.PackageID, out.IDAlt} {
|
||||
if s := string(v); s != "" {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return out.PackageID, nil
|
||||
return "", errors.New("igw: пустой id в ответе ИШ")
|
||||
}
|
||||
|
||||
// Status — состояние пакета у ИШ.
|
||||
// --------------- GET /api/package/status/{id} ---------------
|
||||
|
||||
// Status — состояние отправленного пакета (раздел 2.5.2 инструкции).
|
||||
// Status может быть NEW (новый), SENT (отправлен), ERROR (ошибка).
|
||||
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"`
|
||||
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 == "" {
|
||||
@@ -112,41 +157,79 @@ func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error
|
||||
return Status{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var s Status
|
||||
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
|
||||
// Дублируем альтернативные имена полей (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
|
||||
}
|
||||
|
||||
// Package — описание входящего пакета.
|
||||
// --------------- 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 {
|
||||
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
|
||||
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"`
|
||||
}
|
||||
|
||||
// DecodeBody возвращает декодированное содержимое пакета.
|
||||
func (p Package) DecodeBody() ([]byte, error) {
|
||||
if p.Body == "" {
|
||||
return nil, nil
|
||||
// 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 обязателен")
|
||||
}
|
||||
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)
|
||||
q.Set("channel", filter.Channel)
|
||||
if !filter.Date.IsZero() {
|
||||
q.Set("date", filter.Date.UTC().Format("2006-01-02"))
|
||||
}
|
||||
if !since.IsZero() {
|
||||
q.Set("date", since.UTC().Format(time.RFC3339))
|
||||
if filter.SinceID > 0 {
|
||||
q.Set("id", strconv.Itoa(filter.SinceID))
|
||||
}
|
||||
if packageType != "" {
|
||||
q.Set("type", packageType)
|
||||
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, "")
|
||||
@@ -154,15 +237,83 @@ func (c *Client) ListIncoming(ctx context.Context, channel string, since time.Ti
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
|
||||
// ИШ может вернуть либо массив, либо {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.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("igw: decode ListIncoming: %w", err)
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("igw: decode ListIncoming (object): %w", err)
|
||||
}
|
||||
return out.Items, nil
|
||||
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
|
||||
@@ -182,7 +333,7 @@ func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reade
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept", "application/json, */*")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
switch {
|
||||
case err != nil:
|
||||
@@ -207,3 +358,22 @@ func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reade
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user