// 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" "mime/multipart" "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/), не в HTTP-теле. // // Возвращает идентификатор пакета (как строку — может быть числом или // UUID, зависит от версии ИШ). func (c *Client) SendPackage(ctx context.Context, channel, packageType string, zipBody []byte) (string, error) { if channel == "" { return "", errors.New("igw: channel пустой") } _ = packageType // тип пакета (#M2MTR) кладётся внутрь ZIP/config.xml через pack.go if len(zipBody) == 0 { return "", errors.New("igw: zipBody пустой") } // ИШ REST API ждёт multipart/form-data: поле File (binary ZIP) + // Type (enum InFileType: FILE | ARCHIVE). Для ZIP-пакета — ARCHIVE. // Ответ: {"id": }. См. Swagger /api/package/{channel}/file. var buf bytes.Buffer mw := multipart.NewWriter(&buf) if err := mw.WriteField("Type", "ARCHIVE"); err != nil { return "", fmt.Errorf("igw: multipart Type: %w", err) } fw, err := mw.CreateFormFile("File", "package.zip") if err != nil { return "", fmt.Errorf("igw: multipart File: %w", err) } if _, err := fw.Write(zipBody); err != nil { return "", fmt.Errorf("igw: write zip в multipart: %w", err) } if err := mw.Close(); err != nil { return "", fmt.Errorf("igw: multipart close: %w", err) } path := fmt.Sprintf("/api/package/%s/file", url.PathEscape(channel)) resp, err := c.doRetry(ctx, http.MethodPost, path, &buf, mw.FormDataContentType()) 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 } } } }