├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── archive.go ├── archive_test.go ├── client.go ├── client_test.go ├── date-time.go ├── date-time_test.go ├── debug.go ├── dict.go ├── doc.go ├── doc └── Specifikaciya_API_EPGU_v1_12_1.docx ├── dto.go ├── errors.go ├── esia ├── aas │ ├── README.md │ ├── client.go │ ├── client_test.go │ ├── doc.go │ ├── errors.go │ ├── errors_test.go │ ├── permissions.go │ ├── permissions_test.go │ └── request.go └── signature │ ├── doc.go │ ├── errors.go │ ├── interface.go │ ├── local-cp-gost.go │ ├── local-cp-gost_test.go │ └── nop.go ├── examples ├── esia-token-request │ └── main.go ├── esia-token-update │ └── main.go ├── order-info │ └── main.go └── order-push-chunked │ └── main.go ├── go.mod ├── go.sum ├── multipart.go ├── order-info.go ├── order-meta.go ├── request.go ├── services └── sfr │ ├── complex-types.go │ ├── complex-types_test.go │ ├── dates.go │ ├── dates_test.go │ ├── doc.go │ ├── snils.go │ ├── snils_test.go │ ├── xmlns.go │ └── zdp-10000000109 │ ├── README.md │ ├── applicant.go │ ├── delivery-info.go │ ├── doc.go │ ├── doc │ ├── Specifikaciya_API_EPGU_Dostavka_pensii_i_socialnyh_vyplat_PFR_600109_1.docx │ ├── req-example.xml │ └── trans-example.xml │ ├── edpfr.go │ ├── request.go │ ├── service.go │ ├── xsd │ ├── cmv-types-1.0.1.xsd │ ├── schemas.xsd │ ├── ЗДП_2016-04-15.xsd │ ├── ТипыАФ_2018-12-07.xsd │ ├── ТипыВЗЛ_2014-01-01.xsd │ ├── ТипыОбщие.xsd │ └── УнифТипы_2014-01-01.xsd │ └── zdp.go └── utils ├── doc.go ├── guid.go ├── guid_test.go ├── log-http.go ├── log-http_test.go └── pretty.go /.gitignore: -------------------------------------------------------------------------------- 1 | # .env 2 | .env 3 | 4 | # Playgrounds 5 | _playground 6 | _data 7 | 8 | # IDE 9 | .idea 10 | 11 | # MS Office leftovers 12 | ~$* 13 | 14 | # Binaries for programs and plugins 15 | *.exe 16 | *.exe~ 17 | *.dll 18 | *.so 19 | *.dylib 20 | 21 | # Test binary, built with `go test -c` 22 | *.test 23 | 24 | # Output of the go coverage tool, specifically when used with LiteIDE 25 | *.out 26 | 27 | # Go workspace file 28 | go.work 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # История изменений 2 | 3 | ## v0.5.0 (2024-05-16) 4 | - Изменен вызов `/api/gusmev/push/chunked` в соответствии с изменением схемы спецификации СМЭВ4 (убран параметр `meta`) 5 | - Добавлено поле Settlement в адресный тип СФР 6 | - Обновлена спецификация API ЕПГУ: v1.12 => v1.12.1 7 | - В README добавлены ссылки по теме СМЭВ4 8 | 9 | ## v0.4.0 (2023-12-19) 10 | - Добавлен метод `Client.OrderCancel` 11 | - Добавлен метод `Client.AttachmentDownload` 12 | - Добавлен метод `Client.Dict` 13 | - Структура `File` переименована в `ArchiveFile` 14 | - Обработка HTTP-кодов ошибок в соответствии с Приложением 4 Спецификации 15 | 16 | ## v0.3.1 (2023-12-16) 17 | - Добавлены xsd-схемы для услуги sfr/10000000109-zdp 18 | - Переработана генерация GUID 19 | - Убраны лишние зависимости 20 | - Исправлена ошибка в `Client.OrderPushChunked`: некорректный расчет количества чанков 21 | - Переработана обработка ошибок 22 | - Добавлены тесты 23 | 24 | ## v0.3.0 (2023-12-13) 25 | - Обновлена документация 26 | - Добавлены примеры 27 | - Добавлена услуга "Доставка пенсии и социальных выплат ПФР" и типы СФР 28 | - Добавлена документация "Спецификация API ЕПГУ v1_12" 29 | - Добавлен метод `Client.OrderPush` 30 | - Доработано формирование архива вложений к заявлению 31 | - Доработана обработка ошибок 32 | 33 | ## v0.2.1 (2023-12-12) 34 | - Исправлены ссылки в документации 35 | 36 | ## v0.2.0 (2023-12-11) 37 | Добавлен клиент API ЕПГУ и методы: 38 | - `Client.OrderCreate` 39 | - `Client.OrderPushChunked` 40 | - `Client.OrderInfo` 41 | 42 | ## v0.1.0 (2023-12-01) 43 | - OAuth2-клиент для получения маркера доступа ЕСИА 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Oleg Fomin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-api-epgu 2 | [![Go Reference](https://pkg.go.dev/badge/github.com/ofstudio/go-api-epgu.svg)](https://pkg.go.dev/github.com/ofstudio/go-api-epgu) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/ofstudio/go-api-epgu)](https://goreportcard.com/report/github.com/ofstudio/go-api-epgu) 4 | 5 | REST-клиент для работы с [API Госуслуг (ЕПГУ)](https://partners.gosuslugi.ru/catalog/api_for_gu). 6 | Разработан в соответствии с документом [«Спецификация API ЕПГУ, версия 1.12»](/doc) 7 | 8 | ## Методы 9 | 10 | - [Client.OrderCreate](https://pkg.go.dev/github.com/ofstudio/go-api-epgu#Client.OrderCreate) — создание заявления 11 | - [Client.OrderPushChunked](https://pkg.go.dev/github.com/ofstudio/go-api-epgu#Client.OrderPushChunked) — загрузка архива по частям 12 | - [Client.OrderPush](https://pkg.go.dev/github.com/ofstudio/go-api-epgu#Client.OrderPush) — формирование заявления единым методом 13 | - [Client.OrderInfo](https://pkg.go.dev/github.com/ofstudio/go-api-epgu#Client.OrderInfo) — запрос детальной информации по отправленному заявлению 14 | - [Client.OrderCancel](https://pkg.go.dev/github.com/ofstudio/go-api-epgu#Client.OrderCancel) — отмена заявления 15 | - [Client.AttachmentDownload](https://pkg.go.dev/github.com/ofstudio/go-api-epgu#Client.AttachmentDownload) — скачивание файла вложения созданного заявления 16 | - [Client.Dict](https://pkg.go.dev/github.com/ofstudio/go-api-epgu#Client.Dict) — получение справочных данных 17 | 18 | ## Запрос согласия и получение маркера доступа ЕСИА 19 | 20 | - [esia/aas](https://pkg.go.dev/github.com/ofstudio/go-api-epgu/esia/aas) — OAuth2-клиент для получения маркера доступа ЕСИА 21 | - [esia/signature](https://pkg.go.dev/github.com/ofstudio/go-api-epgu/esia/signature) — подпись запросов к ЕСИА 22 | 23 | ## Услуги API ЕПГУ 24 | 25 | - [services/sfr/10000000109-zdp](https://pkg.go.dev/github.com/ofstudio/go-api-epgu/services/sfr/10000000109-zdp) — "Доставка пенсии и социальных выплат ПФР" 26 | 27 | 28 | ## Примеры 29 | - [Запрос согласия пользователя и получения маркера доступа](/examples/esia-token-request/main.go) 30 | - [Обновление маркера доступа](/examples/esia-token-update/main.go) 31 | - [Создание заявления и загрузка архива по частям](/examples/order-push-chunked/main.go) 32 | - [Получение детальной информации по отправленному заявлению](/examples/order-info/main.go) 33 | 34 | ## Установка 35 | 36 | ``` 37 | go get -u github.com/ofstudio/go-api-epgu 38 | ``` 39 | ## Системные требования 40 | 41 | - Go 1.21+ 42 | - Для подписания запросов к ЕСИА с помощью 43 | [LocalCryptoPro](https://pkg.go.dev/github.com/ofstudio/go-api-epgu/esia/signature#LocalCryptoPro) — 44 | КриптоПро CSP 5.0+ и сертификат для подписания запросов 45 | 46 | 47 | ## Регламентные требования 48 | 1. Информационная система должна быть зарегистрирована на Технологическом портале ЕСИА: 49 | продуктовом или тестовом (SVCDEV) 50 | 2. Для ИС должен быть выпущен необходимый сертификат 51 | 3. Публичная часть сертификата должна быть загружена на Технологический портал ЕСИА 52 | 4. Выполнены все необходимые шаги регламента и согласованы заявки на подключения ИС к тестовым 53 | или продуктовым средам ЕСИА и ЕПГУ 54 | 55 | ## Руководящие документы 56 | 1. [Портал API Госуслуг](https://partners.gosuslugi.ru/catalog/api_for_gu): 57 | регламенты подключения, руководства, спецификация API ЕПГУ и отдельных услуг 58 | 2. [Методические рекомендации по интеграции с REST API Цифрового профиля](https://digital.gov.ru/ru/documents/7166/) 59 | 3. [Методические рекомендации по использованию ЕСИА](https://digital.gov.ru/ru/documents/6186/) 60 | 4. [Руководство пользователя ЕСИА](https://digital.gov.ru/ru/documents/6182/) 61 | 5. [Руководство пользователя технологического портала ЕСИА](https://digital.gov.ru/ru/documents/6190/) 62 | 63 | ## Ссылки 64 | 65 | ### ЕСИА 66 | - Тестовая среда (SVCDEV): https://esia-portal1.test.gosuslugi.ru 67 | - Продуктовая среда: https://esia.gosuslugi.ru 68 | 69 | ### Технологический портал ЕСИА 70 | - Тестовая среда (SVCDEV): https://esia-portal1.test.gosuslugi.ru/console/tech 71 | - Продуктовая среда: https://esia.gosuslugi.ru/console/tech/ 72 | 73 | ### Список согласий предоставленных пользователем 74 | - Тестовая среда (SVCDEV): https://svcdev-betalk.test.gosuslugi.ru/settings/third-party/agreements/acting 75 | - Продуктовая среда: https://lk.gosuslugi.ru/settings/third-party/agreements/acting 76 | 77 | ### Подключение 78 | 79 | #### Подключение через TLS 80 | Прямое подключение к API ЕПГУ через TLS-соединение. 81 | Подробнее см "Спецификация API ЕПГУ версия 1.12.1", раздел "1.2. Реализация подключения по ГОСТ TLS" 82 | 83 | - Тестовая среда (SVCDEV): https://svcdev-beta.test.gosuslugi.ru 84 | - Продуктовая среда: https://lk.gosuslugi.ru 85 | 86 | #### Подключение через СМЭВ4 (ПОДД) 87 | Подключение через регламентированный запрос типа REST-сервис в среде СМЭВ4. 88 | Подробнее см "Спецификация API ЕПГУ версия 1.12.1", раздел "1.3. Подключение через СМЭВ4". 89 | 90 | - Тестовая среда (SVCDEV): https://lkuv.gosuslugi.ru/paip-portal/#/podd/open-api/specifications/card/e28f1ae0-0fdc-431a-9adb-17173564d1db 91 | - Продуктовая среда: _на 16 мая 2024 "Спецификация API ЕПГУ" не опубликована в промышленной среде СМЭВ 4._ 92 | 93 | ### СМЭВ4 94 | - [Документы СМЭВ 4 (ПОДД)](https://info.gosuslugi.ru/docs/section/СМЭВ_4_%28ПОДД%29/): регламенты подключения, руководство администратора, дистрибутив Агента ПОДД 95 | - [Коротко о СМЭВ 4 (ПОДД)](https://info.gosuslugi.ru/articles/Коротко_о_СМЭВ_4_(ПОДД)/) 96 | - [Обмен в СМЭВ4 c использованием REST-сервиса](https://info.gosuslugi.ru/articles/Обмен_в_СМЭВ4_c_использованием_REST-сервиса/) 97 | - [Материалы по теме "СМЭВ 4 (ПОДД)"](https://info.gosuslugi.ru/sections/СМЭВ_4_(ПОДД)/) 98 | 99 | ## Лицензия 100 | Распространяется по лицензии MIT. Более подробная информация в файле LICENSE. 101 | -------------------------------------------------------------------------------- /archive.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "fmt" 7 | ) 8 | 9 | // ArchiveFile - файл вложения для формирования архива [Archive] к создаваемому заявлению 10 | type ArchiveFile struct { 11 | Filename string // Имя файла с расширением. Пример: "req_346ee59c-a428-42f6-342e-c780dd2e278e.xml" 12 | Data []byte // Содержимое файла 13 | } 14 | 15 | // Archive - архив вложений к создаваемому заявлению. 16 | // Используется для методов [Client.OrderPush] и [Client.OrderPushChunked]. 17 | type Archive struct { 18 | Name string // Имя архива (без расширения). Пример: "35002123456-archive" 19 | Data []byte // Содержимое архива в zip-формате 20 | } 21 | 22 | // NewArchive - создает архив из файлов вложений. 23 | // В случае ошибки возвращает [ErrZip]. 24 | func NewArchive(name string, files ...ArchiveFile) (*Archive, error) { 25 | if len(files) == 0 { 26 | return nil, ErrNoFiles 27 | } 28 | 29 | var b bytes.Buffer 30 | zipWriter := zip.NewWriter(&b) 31 | for _, file := range files { 32 | fileWriter, err := zipWriter.Create(file.Filename) 33 | if err != nil { 34 | return nil, fmt.Errorf("%w: %w", ErrZip, err) 35 | } 36 | if _, err = fileWriter.Write(file.Data); err != nil { 37 | return nil, fmt.Errorf("%w: %w", ErrZip, err) 38 | } 39 | } 40 | 41 | if err := zipWriter.Close(); err != nil { 42 | return nil, fmt.Errorf("%w: %w", ErrZip, err) 43 | } 44 | 45 | return &Archive{ 46 | Name: name, 47 | Data: b.Bytes(), 48 | }, nil 49 | } 50 | -------------------------------------------------------------------------------- /archive_test.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "io" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/suite" 10 | ) 11 | 12 | func TestArchive(t *testing.T) { 13 | suite.Run(t, new(suiteTestArchive)) 14 | } 15 | 16 | type suiteTestArchive struct { 17 | suite.Suite 18 | } 19 | 20 | func (suite *suiteTestArchive) TestNewArchive() { 21 | 22 | suite.Run("success", func() { 23 | file1 := ArchiveFile{Filename: "file1.txt", Data: []byte("This is file 1")} 24 | file2 := ArchiveFile{Filename: "file2.txt", Data: []byte("This is file 2")} 25 | 26 | archive, err := NewArchive("test", file1, file2) 27 | suite.NoError(err) 28 | suite.Require().NotNil(archive) 29 | 30 | r, err := zip.NewReader(bytes.NewReader(archive.Data), int64(len(archive.Data))) 31 | suite.Require().NoError(err) 32 | suite.Require().Len(r.File, 2) 33 | 34 | suite.Equal(file1, suite.unZip(r.File[0])) 35 | suite.Equal(file2, suite.unZip(r.File[1])) 36 | }) 37 | 38 | suite.Run("no files", func() { 39 | archive, err := NewArchive("test") 40 | suite.ErrorIs(err, ErrNoFiles) 41 | suite.Nil(archive) 42 | }) 43 | 44 | } 45 | 46 | func (suite *suiteTestArchive) unZip(zipFile *zip.File) ArchiveFile { 47 | f, err := zipFile.Open() 48 | suite.Require().NoError(err) 49 | b := bytes.Buffer{} 50 | _, err = io.Copy(&b, f) 51 | suite.Require().NoError(err) 52 | suite.NoError(f.Close()) 53 | return ArchiveFile{ 54 | Filename: zipFile.Name, 55 | Data: b.Bytes(), 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "mime/multipart" 8 | "net/http" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/ofstudio/go-api-epgu/utils" 15 | ) 16 | 17 | // DefaultChunkSize - размер чанка по умолчанию для метода [Client.OrderPushChunked]. 18 | // Если размер архива вложения будет больше, то метод отправит архив несколькими запросами. 19 | // Значение можно изменить с помощью [Client.WithChunkSize]. 20 | // 21 | // Подробнее см. "Спецификация API ЕПГУ версия 1.12", 22 | // раздел "2.1.3 Отправка заявления (загрузка архива по частям)". 23 | const DefaultChunkSize = 5_000_000 24 | 25 | // DefaultArchiveName - имя архива по умолчанию для методов [Client.OrderPush] и [Client.OrderPushChunked]. 26 | // Используется, если в [Archive].Name не передано имя архива. 27 | const DefaultArchiveName = "archive" 28 | 29 | // Client - REST-клиент для API Госуслуг. 30 | type Client struct { 31 | baseURI string 32 | httpClient *http.Client 33 | chunkSize int 34 | debug bool 35 | logger utils.Logger 36 | } 37 | 38 | // NewClient - конструктор [Client]. 39 | func NewClient(baseURI string) *Client { 40 | return &Client{ 41 | baseURI: baseURI, 42 | httpClient: &http.Client{}, 43 | chunkSize: DefaultChunkSize, 44 | } 45 | } 46 | 47 | // WithDebug - включает логирование HTTP-запросов и ответов к ЕПГУ. 48 | // Формат лога: 49 | // 50 | // >>> Request to {url} 51 | // ... 52 | // {полный HTTP-запрос} 53 | // ... 54 | // <<< Response from {url} 55 | // ... 56 | // {полный HTTP-ответ} 57 | // ... 58 | func (c *Client) WithDebug(logger utils.Logger) *Client { 59 | c.logger = logger 60 | c.debug = logger != nil 61 | return c 62 | } 63 | 64 | // WithHTTPClient - устанавливает http-клиент для запросов к ЕПГУ. 65 | func (c *Client) WithHTTPClient(httpClient *http.Client) *Client { 66 | if httpClient != nil { 67 | c.httpClient = httpClient 68 | } 69 | return c 70 | } 71 | 72 | // WithChunkSize устанавливает максимальный размер чанка для метода [Client.OrderPushChunked]. 73 | // По умолчанию используется [DefaultChunkSize]. 74 | // 75 | // Подробнее см "Спецификация API ЕПГУ версия 1.12", 76 | // раздел "2.1.3 Отправка заявления (загрузка архива по частям)" 77 | func (c *Client) WithChunkSize(n int) *Client { 78 | if n > 0 { 79 | c.chunkSize = n 80 | } 81 | return c 82 | } 83 | 84 | // OrderCreate - создание заявления. 85 | // 86 | // POST /api/gusmev/order 87 | // 88 | // Подробнее см. "Спецификация API ЕПГУ версия 1.12", 89 | // раздел "2.1.2 Создание заявления". 90 | // 91 | // В случае успеха возвращает номер созданного заявления. 92 | // В случае ошибки возвращает цепочку из [ErrOrderCreate] и следующих возможных ошибок: 93 | // - [ErrRequest] - ошибка HTTP-запроса 94 | // - [ErrJSONUnmarshal] - ошибка разбора ответа 95 | // - [ErrWrongOrderID] - в ответе не передан ID заявления 96 | // - HTTP-ошибок ErrStatusXXXX (например, [ErrStatusUnauthorized]) 97 | // - Ошибок ЕПГУ: ErrCodeXXXX (например, [ErrCodeBadRequest]) 98 | func (c *Client) OrderCreate(token string, meta OrderMeta) (int, error) { 99 | orderIdResponse := &dtoOrderIdResponse{} 100 | if err := c.requestJSON( 101 | http.MethodPost, 102 | "/api/gusmev/order", 103 | "application/json; charset=utf-8", 104 | token, 105 | bytes.NewReader(meta.JSON()), 106 | orderIdResponse, 107 | ); err != nil { 108 | return 0, fmt.Errorf("%w: %w", ErrOrderCreate, err) 109 | } 110 | if orderIdResponse.OrderId == 0 { 111 | return 0, fmt.Errorf("%w: %w", ErrOrderCreate, ErrWrongOrderID) 112 | } 113 | return orderIdResponse.OrderId, nil 114 | } 115 | 116 | // OrderPushChunked - загрузка архива по частям. 117 | // 118 | // POST /api/gusmev/push/chunked 119 | // 120 | // Подробнее см "Спецификация API ЕПГУ версия 1.12", 121 | // раздел "2.1.3 Отправка заявления (загрузка архива по частям)" 122 | // 123 | // Максимальный размер чанка по умолчанию: [DefaultChunkSize], 124 | // может быть изменен с помощью [Client.WithChunkSize]. 125 | // 126 | // В случае ошибки возвращает цепочку из [ErrPushChunked] и следующих возможных ошибок: 127 | // - [ErrNilArchive] - не передан архив 128 | // - [ErrRequest] - ошибка HTTP-запроса 129 | // - [ErrMultipartBody] - ошибка подготовки multipart-содержимого 130 | // - [ErrWrongOrderID] - в ответе не передан или передан некорректный ID заявления 131 | // - HTTP-ошибок ErrStatusXXXX (например, [ErrStatusUnauthorized]) 132 | // - Ошибок ЕПГУ ErrCodeXXXX (например, [ErrCodeBadRequest]) 133 | func (c *Client) OrderPushChunked(token string, orderId int, archive *Archive) error { 134 | if archive == nil || len(archive.Data) == 0 { 135 | return fmt.Errorf("%w: %w", ErrPushChunked, ErrNilArchive) 136 | } 137 | 138 | filename := archive.Name 139 | if archive.Name == "" { 140 | filename = DefaultArchiveName 141 | } 142 | extension := ".zip" 143 | 144 | total := 1 + (len(archive.Data)-1)/(c.chunkSize) 145 | 146 | for current := 0; current < total; current++ { 147 | // prepare chunk 148 | end := current*c.chunkSize + c.chunkSize 149 | if end > len(archive.Data) { 150 | end = len(archive.Data) 151 | } 152 | chunk := archive.Data[current*c.chunkSize : end] 153 | 154 | if total > 1 { 155 | extension = fmt.Sprintf(".z%03d", current+1) 156 | } 157 | 158 | // prepare multipart body 159 | body := &bytes.Buffer{} 160 | w := multipart.NewWriter(body) 161 | builder := newMultipartBuilder(w). 162 | withOrderId(orderId). 163 | withFile(filename+extension, chunk) 164 | if total > 1 { 165 | builder = builder.withChunkNum(current, total) 166 | } 167 | if err := builder.build(); err != nil { 168 | return fmt.Errorf("%w: %w", ErrPushChunked, err) 169 | } 170 | 171 | // make request 172 | orderIdResponse := &dtoOrderIdResponse{} 173 | if err := c.requestJSON( 174 | http.MethodPost, 175 | "/api/gusmev/push/chunked", 176 | "multipart/form-data; boundary="+w.Boundary(), 177 | token, 178 | body, 179 | orderIdResponse, 180 | ); err != nil { 181 | return fmt.Errorf("%w: %w", ErrPushChunked, err) 182 | } 183 | if orderIdResponse.OrderId != orderId { 184 | return fmt.Errorf("%w: %w", ErrPushChunked, ErrWrongOrderID) 185 | } 186 | } 187 | 188 | return nil 189 | } 190 | 191 | // OrderPush - формирование заявления единым методом. 192 | // 193 | // POST /api/gusmev/push 194 | // 195 | // Подробнее см "Спецификация API ЕПГУ версия 1.12", 196 | // раздел "2.1.4 Формирование заявления единым методом" 197 | // 198 | // В случае успеха возвращает номер созданного заявления. 199 | // В случае ошибки возвращает цепочку из [ErrPush] и следующих возможных ошибок: 200 | // - [ErrNilArchive] - не передан архив 201 | // - [ErrRequest] - ошибка HTTP-запроса 202 | // - [ErrMultipartBody] - ошибка подготовки multipart-содержимого 203 | // - [ErrWrongOrderID] - в ответе не передан ID заявления 204 | // - HTTP-ошибок ErrStatusXXXX (например, [ErrStatusUnauthorized]) 205 | // - Ошибок ЕПГУ ErrCodeXXXX (например, [ErrCodeBadRequest]) 206 | func (c *Client) OrderPush(token string, meta OrderMeta, archive *Archive) (int, error) { 207 | if archive == nil || len(archive.Data) == 0 { 208 | return 0, fmt.Errorf("%w: %w", ErrPush, ErrNilArchive) 209 | } 210 | 211 | filename := archive.Name 212 | if archive.Name == "" { 213 | filename = DefaultArchiveName 214 | } 215 | 216 | body := &bytes.Buffer{} 217 | w := multipart.NewWriter(body) 218 | if err := newMultipartBuilder(w). 219 | withMeta(meta). 220 | withFile(filename+".zip", archive.Data). 221 | build(); err != nil { 222 | return 0, fmt.Errorf("%w: %w", ErrPush, err) 223 | } 224 | 225 | orderIdResponse := &dtoOrderIdResponse{} 226 | if err := c.requestJSON( 227 | http.MethodPost, 228 | "/api/gusmev/push", 229 | "multipart/form-data; boundary="+w.Boundary(), 230 | token, 231 | body, 232 | orderIdResponse, 233 | ); err != nil { 234 | return 0, fmt.Errorf("%w: %w", ErrPush, err) 235 | } 236 | if orderIdResponse.OrderId == 0 { 237 | return 0, fmt.Errorf("%w: %w", ErrPush, ErrWrongOrderID) 238 | } 239 | 240 | return orderIdResponse.OrderId, nil 241 | } 242 | 243 | // OrderInfo - запрос детальной информации по отправленному заявлению. 244 | // 245 | // POST /api/gusmev/order/{orderId} 246 | // 247 | // Подробнее см "Спецификация API ЕПГУ версия 1.12", 248 | // раздел "2.4. Получение деталей по заявлению". 249 | // 250 | // В случае успеха возвращает детальную информацию по заявлению. 251 | // В случае ошибки возвращает цепочку из [ErrOrderInfo] и следующих возможных ошибок: 252 | // - [ErrRequest] - ошибка HTTP-запроса 253 | // - [ErrJSONUnmarshal] - ошибка разбора ответа 254 | // - HTTP-ошибок ErrStatusXXXX (например, [ErrStatusUnauthorized]) 255 | // - Ошибок ЕПГУ: ErrCodeXXXX (например, [ErrCodeBadRequest]) 256 | func (c *Client) OrderInfo(token string, orderId int) (*OrderInfo, error) { 257 | 258 | orderInfoResponse := &dtoOrderInfoResponse{} 259 | if err := c.requestJSON( 260 | http.MethodPost, 261 | fmt.Sprintf("/api/gusmev/order/%d", orderId), 262 | "", 263 | token, 264 | nil, 265 | orderInfoResponse, 266 | ); err != nil { 267 | return nil, fmt.Errorf("%w: %w", ErrOrderInfo, err) 268 | } 269 | 270 | orderInfo := &OrderInfo{ 271 | Code: orderInfoResponse.Code, 272 | Message: orderInfoResponse.Message, 273 | MessageId: orderInfoResponse.MessageId, 274 | } 275 | 276 | // unmarshal order field 277 | if orderInfoResponse.Order != "" { 278 | orderInfo.Order = &OrderDetails{} 279 | if err := json.Unmarshal([]byte(orderInfoResponse.Order), orderInfo.Order); err != nil { 280 | return nil, fmt.Errorf("%w: %w: %w", ErrOrderInfo, ErrJSONUnmarshal, err) 281 | } 282 | } 283 | 284 | return orderInfo, nil 285 | } 286 | 287 | // OrderCancel - отмена заявления. 288 | // 289 | // POST /api/gusmev/order/{orderId}/cancel 290 | // 291 | // Подробнее см "Спецификация API ЕПГУ версия 1.12", 292 | // раздел "2.2. Отмена заявления". 293 | // 294 | // В случае ошибки возвращает цепочку из [ErrOrderCancel] и следующих возможных ошибок: 295 | // - [ErrRequest] - ошибка HTTP-запроса 296 | // - [ErrJSONUnmarshal] - ошибка разбора ответа 297 | // - HTTP-ошибок ErrStatusXXXX (например, [ErrStatusUnauthorized]) 298 | // - Ошибок ЕПГУ: ErrCodeXXXX (например, [ErrCodeCancelNotAllowed]) 299 | // 300 | // Примечание. В настоящий момент (декабрь 2023) вызов метода возвращает ошибку HTTP 400 Bad Request: 301 | // 302 | // { 303 | // "code":"bad_request", 304 | // "message":"Required request parameter 'reason' for method parameter type String is not present" 305 | // } 306 | // 307 | // При этом, параметр reason не описан в спецификации. 308 | // На данный момент ни одна из доступных услуг API ЕПГУ не предусматривает 309 | // возможность отмены. Вероятно, спецификация метода будет изменена в будущем. 310 | func (c *Client) OrderCancel(token string, orderId int) error { 311 | if _, err := c.requestBody( 312 | http.MethodPost, 313 | fmt.Sprintf("/api/gusmev/order/%d/cancel", orderId), 314 | "application/json; charset=utf-8", 315 | token, 316 | nil, 317 | ); err != nil { 318 | return fmt.Errorf("%w: %w", ErrOrderCancel, err) 319 | } 320 | return nil 321 | } 322 | 323 | // AttachmentDownload - скачивание файла вложения созданного заявления. 324 | // 325 | // GET /api/storage/v2/files/{objectId}/{objectType}/download?mnemonic={mnemonic} 326 | // 327 | // Параметр link - значение поля [OrderAttachmentFile].Link из ответа метода [Client.OrderInfo]. 328 | // Подробнее см "Спецификация API ЕПГУ версия 1.12", 329 | // раздел "4. Скачивание файла". 330 | // 331 | // В случае успеха возвращает содержимое файла. 332 | // В случае ошибки возвращает цепочку из [ErrAttachmentDownload] и следующих возможных ошибок: 333 | // - [ErrRequest] - ошибка HTTP-запроса 334 | // - [ErrInvalidFileLink] - некорректный параметр link 335 | // - HTTP-ошибок ErrStatusXXXX (например, [ErrStatusUnauthorized]) 336 | // - Ошибок ЕПГУ: ErrCodeXXXX (например, [ErrCodeAccessDeniedSystem]) 337 | func (c *Client) AttachmentDownload(token string, link string) ([]byte, error) { 338 | uri, err := attachmentURI(link) 339 | if err != nil { 340 | return nil, fmt.Errorf("%w: %w", ErrAttachmentDownload, err) 341 | } 342 | 343 | resBody, err := c.requestBody( 344 | http.MethodGet, 345 | "/api/storage/v2/files"+uri, 346 | "", 347 | token, 348 | nil, 349 | ) 350 | if err != nil { 351 | return nil, fmt.Errorf("%w: %w", ErrAttachmentDownload, err) 352 | } 353 | 354 | return resBody, nil 355 | } 356 | 357 | // reAttachmentURI - регулярное выражение для разбора URI вида: 358 | // "terrabyte://00/1230254874/req_8d8567db-d445-4759-a122-6b4cefeca22c.xml/2" 359 | // $1 - {objectId} 360 | // $2 - {mnemonic} 361 | // $3 - {objectType} 362 | var reAttachmentURI = regexp.MustCompile(`^terrabyte://.*/(.*)/(.*)/(.*)$`) 363 | 364 | // attachmentURI - формирует URI для скачивания файла вложения. 365 | // Параметр link - значение поля [OrderAttachmentFile.Link]. 366 | // Возвращает URI вида: 367 | // 368 | // /{objectId}/{objectType}/download?mnemonic={mnemonic} 369 | // 370 | // либо ошибку [ErrInvalidFileLink], если передан некорректный параметр link. 371 | func attachmentURI(link string) (string, error) { 372 | matches := reAttachmentURI.FindStringSubmatch(link) 373 | if len(matches) != 4 { 374 | return "", ErrInvalidFileLink 375 | } 376 | return fmt.Sprintf("/%s/%s/download?mnemonic=%s", matches[1], matches[3], matches[2]), nil 377 | } 378 | 379 | // Dict - получение справочных данных. 380 | // 381 | // POST /api/nsi/v1/dictionary/{code} 382 | // 383 | // Подробнее см "Спецификация API ЕПГУ версия 1.12", 384 | // раздел "3. Получение справочных данных". 385 | // 386 | // Параметры: 387 | // 388 | // - code - код справочника. Примеры: "EXTERNAL_BIC", "TO_PFR" 389 | // - filter - тип справочника (плоский [DictFilterOneLevel] или иерархический [DictFilterSubTree]) 390 | // - parent - код родительского элемента (необязательный) 391 | // - pageNum - номер необходимой страницы (необязательный) 392 | // - pageSize - количество записей на странице (необязательный) 393 | // 394 | // Примечание: не все справочники поддерживают параметры parent, pageNum и pageSize. 395 | // 396 | // В случае успеха возвращает элементы справочника с учетом pageNum и pageSize, 397 | // а также общее количество найденных элементов. 398 | // В случае ошибки возвращает цепочку из [ErrDict] и следующих возможных ошибок: 399 | // - [ErrRequest] - ошибка HTTP-запроса 400 | // - [ErrJSONUnmarshal] - ошибка разбора ответа 401 | // - [ErrDictResponse] - ошибка получения справочных данных c указанием code и message из ответа 402 | // - HTTP-ошибок ErrStatusXXXX (например, [ErrStatusBadRequest]) 403 | // - Ошибок ЕПГУ: ErrCodeXXXX (например, [ErrCodeBadRequest]) 404 | func (c *Client) Dict(code string, filter, parent string, pageNum, pageSize int) ([]DictItem, int, error) { 405 | reqBody, _ := json.Marshal(&dtoDictRequest{ 406 | TreeFiltering: filter, 407 | ParentRefItemValue: parent, 408 | PageNum: pageNum, 409 | PageSize: pageSize, 410 | }) 411 | 412 | dictResponse := &dtoDictResponse{} 413 | if err := c.requestJSON( 414 | http.MethodPost, 415 | fmt.Sprintf("/api/nsi/v1/dictionary/%s", code), 416 | "application/json; charset=utf-8", 417 | "", 418 | bytes.NewReader(reqBody), 419 | dictResponse, 420 | ); err != nil { 421 | return nil, 0, fmt.Errorf("%w: %w", ErrDict, err) 422 | } 423 | 424 | if dictResponse.Error.Code != 0 && len(dictResponse.Items) == 0 { 425 | return nil, 0, fmt.Errorf("%w: %w", ErrDict, dictError(dictResponse.Error)) 426 | } 427 | 428 | return dictResponse.Items, dictResponse.Total, nil 429 | } 430 | 431 | // GetOrdersStatus - Получение статусов заявлений по переданному списку заявлений 432 | // 433 | // GET /api/gusmev/order/getOrdersStatus/?pageNum={n}&pageSize={m}&orderIds={array[integer]} 434 | // 435 | // Параметры: 436 | // - pageNum - номер необходимой страницы. Начало нумерации с 0 437 | // - pageSize - количество записей на странице 438 | // - orderIds - номера заявлений ([integer]) 439 | func (c *Client) GetOrdersStatus(token string, pageNum, pageSize int, orderIds []int) (*OrdersStatus, error) { 440 | // Convert []int to []string 441 | strOrderIds := make([]string, len(orderIds)) 442 | for i, num := range orderIds { 443 | strOrderIds[i] = strconv.Itoa(num) 444 | } 445 | 446 | ordersStatus := &OrdersStatus{} 447 | if err := c.requestJSON( 448 | http.MethodGet, 449 | fmt.Sprintf("/api/gusmev/order/getOrdersStatus/?pageNum=%d&pageSize=%d&orderIds=%s", pageNum, pageSize, strings.Join(strOrderIds, ", ")), 450 | "application/json; charset=utf-8", 451 | token, 452 | nil, 453 | ordersStatus, 454 | ); err != nil { 455 | return nil, fmt.Errorf("%w: %w", ErrGetOrdersStatus, err) 456 | } 457 | 458 | return ordersStatus, nil 459 | } 460 | 461 | // Получение статусов всех заявлений с даты обновления статуса 462 | // 463 | // GET /api/gusmev/order/getUpdatedAfter?pageNum={n}&pageSize={m}&updatedAfter={timestamp} 464 | // 465 | // Параметры: 466 | // - pageNum - номер необходимой страницы. Начало нумерации с 0 467 | // - pageSize - количество записей на странице 468 | // - updatedAfter – дата и время в формате [YYYY-MM-DD'T'HH:mm:ss.SSS], после которых были обновлены статусы 469 | func (c *Client) GetUpdatedAfter(token string, pageNum, pageSize int, updatedAfter time.Time) (*OrdersStatus, error) { 470 | ordersStatus := &OrdersStatus{} 471 | if err := c.requestJSON( 472 | http.MethodGet, 473 | fmt.Sprintf("/api/gusmev/order/getUpdatedAfter/?pageNum=%d&pageSize=%d&updatedAfter=%s", pageNum, pageSize, DateTime{updatedAfter}), 474 | "", 475 | token, 476 | nil, 477 | ordersStatus, 478 | ); err != nil { 479 | return nil, fmt.Errorf("%w: %w", ErrGetUpdatedAfter, err) 480 | } 481 | 482 | return ordersStatus, nil 483 | } 484 | -------------------------------------------------------------------------------- /date-time.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | // "date": "2023-11-02T07:27:22.586+0300" 10 | const apipguLayout = "2006-01-02T15:04:05.000-0700" 11 | const apipguLayoutWithoutOffset = "2006-01-02T15:04:05.000" 12 | 13 | // DateTime - дата и время в формате API ЕПГУ. 14 | // 15 | // 2023-11-02T07:27:22.586+0300 16 | type DateTime struct { 17 | time.Time 18 | } 19 | 20 | func (d *DateTime) UnmarshalJSON(b []byte) (err error) { 21 | s := string(b) 22 | if s == "null" { 23 | d.Time = time.Time{} 24 | return 25 | } 26 | s = strings.Trim(string(b), `"`) 27 | d.Time, err = time.Parse(apipguLayout, s) 28 | return 29 | } 30 | 31 | func (d DateTime) MarshalJSON() ([]byte, error) { 32 | if d.Time.IsZero() { 33 | return []byte("null"), nil 34 | } 35 | return []byte(fmt.Sprintf(`"%s"`, d.Time.Format(apipguLayout))), nil 36 | } 37 | 38 | func (d DateTime) GoString() string { 39 | return d.Time.Format(apipguLayoutWithoutOffset) 40 | } 41 | -------------------------------------------------------------------------------- /date-time_test.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/suite" 9 | ) 10 | 11 | func TestDateTime(t *testing.T) { 12 | suite.Run(t, new(suiteTestDateTime)) 13 | } 14 | 15 | type suiteTestDateTime struct { 16 | suite.Suite 17 | } 18 | 19 | func (suite *suiteTestDateTime) TestMarshalJSON() { 20 | suite.Run("2023-11-02T07:27:22", func() { 21 | dt := DateTime{time.Date(2023, 11, 2, 7, 27, 22, 0, time.UTC)} 22 | b, err := json.Marshal(dt) 23 | suite.NoError(err) 24 | suite.Equal(`"2023-11-02T07:27:22.000+0000"`, string(b)) 25 | }) 26 | 27 | suite.Run("null time", func() { 28 | dt := DateTime{time.Time{}} 29 | b, err := json.Marshal(dt) 30 | suite.NoError(err) 31 | suite.Equal(`null`, string(b)) 32 | }) 33 | 34 | } 35 | 36 | func (suite *suiteTestDateTime) TestUnmarshalJSON() { 37 | suite.Run("2023-11-02T07:27:22", func() { 38 | var dt DateTime 39 | 40 | err := json.Unmarshal([]byte(`"2023-11-02T07:27:22.000+0000"`), &dt) 41 | suite.NoError(err) 42 | suite.NoError(err) 43 | suite.Equal(time.Date(2023, 11, 2, 7, 27, 22, 0, dt.Time.Location()), dt.Time) 44 | }) 45 | 46 | suite.Run("null time", func() { 47 | var dt DateTime 48 | err := json.Unmarshal([]byte(`null`), &dt) 49 | suite.NoError(err) 50 | suite.Equal(time.Time{}, dt.Time) 51 | }) 52 | 53 | suite.Run("empty string", func() { 54 | var dt DateTime 55 | err := json.Unmarshal([]byte(`""`), &dt) 56 | suite.Error(err) 57 | suite.Equal(time.Time{}, dt.Time) 58 | }) 59 | 60 | suite.Run("invalid string", func() { 61 | var dt DateTime 62 | err := json.Unmarshal([]byte(`"invalid string"`), &dt) 63 | suite.Error(err) 64 | suite.Equal(time.Time{}, dt.Time) 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/ofstudio/go-api-epgu/utils" 7 | ) 8 | 9 | func (c *Client) logReq(req *http.Request) { 10 | if c.debug { 11 | utils.LogReq(req, c.logger) 12 | } 13 | } 14 | 15 | func (c *Client) logRes(res *http.Response) { 16 | if c.debug { 17 | utils.LogRes(res, c.logger) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /dict.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | // Типы запрашиваемого справочника (плоский / иерархический) 4 | // для метода [Client.Dict]. 5 | const ( 6 | DictFilterOneLevel string = "ONELEVEL" // Плоский справочник 7 | DictFilterSubTree string = "SUBTREE" // Иерархический справочник 8 | ) 9 | 10 | // DictItem - элемент справочника. 11 | // 12 | // Подробнее см. "Спецификация API ЕПГУ версия 1.12", 13 | // раздел "3. Получение справочных данных". 14 | // 15 | // Пример элемента справочника EXTERNAL_BIC: 16 | // 17 | // { 18 | // "value": "044525974", 19 | // "title": "044525974 - АО \"Тинькофф Банк\" г Москва", 20 | // "isLeaf": true, 21 | // "children": [], 22 | // "attributes": [ 23 | // { 24 | // "name": "ID", 25 | // "type": "STRING", 26 | // "value": { 27 | // "asString": "044525974", 28 | // "typeOfValue": "STRING", 29 | // "value": "044525974" 30 | // }, 31 | // "valueAsOfType": "044525974" 32 | // }, 33 | // { 34 | // "name": "NAME", 35 | // "type": "STRING", 36 | // "value": { 37 | // "asString": "АО \"Тинькофф Банк\" г Москва", 38 | // "typeOfValue": "STRING", 39 | // "value": "АО \"Тинькофф Банк\" г Москва" 40 | // }, 41 | // "valueAsOfType": "АО \"Тинькофф Банк\" г Москва" 42 | // }, 43 | // { 44 | // "name": "BIC", 45 | // "type": "STRING", 46 | // "value": { 47 | // "asString": "044525974", 48 | // "typeOfValue": "STRING", 49 | // "value": "044525974" 50 | // }, 51 | // "valueAsOfType": "044525974" 52 | // }, 53 | // { 54 | // "name": "CORR_ACCOUNT", 55 | // "type": "STRING", 56 | // "value": { 57 | // "asString": "30101810145250000974", 58 | // "typeOfValue": "STRING", 59 | // "value": "30101810145250000974" 60 | // }, 61 | // "valueAsOfType": "30101810145250000974" 62 | // } 63 | // ], 64 | // "attributeValues": { 65 | // "ID": "044525974", 66 | // "CORR_ACCOUNT": "30101810145250000974", 67 | // "BIC": "044525974", 68 | // "NAME": "АО \"Тинькофф Банк\" г Москва" 69 | // } 70 | // } 71 | // 72 | // Пример элемента справочника TO_PFR: 73 | // 74 | // { 75 | // "value": "087109", 76 | // "title": "Клиентская служба «Замоскворечье, Якиманка» по г. Москве и МО", 77 | // "isLeaf": true, 78 | // "children": [], 79 | // "attributes": [], 80 | // "attributeValues": {} 81 | // }, 82 | type DictItem struct { 83 | Value string `json:"value"` // Код элемента справочника 84 | ParentValue string `json:"parentValue,omitempty"` // Код родительского элемента 85 | Title string `json:"title"` // Наименование элемента 86 | IsLeaf bool `json:"isLeaf"` // [?] Признак наличия подчинённых элементов 87 | Children []map[string]any `json:"children"` // Подчинённые элементы 88 | Attributes []DictAttribute `json:"attributes"` // Дополнительные атрибуты элемента справочника [детально] 89 | AttributeValues map[string]any `json:"attributeValues"` // Список значений дополнительных атрибутов элемента справочника [кратко] 90 | } 91 | 92 | // DictAttribute - дополнительный атрибут элемента справочника из структуры [DictItem]. 93 | type DictAttribute struct { 94 | Name string `json:"name"` // [Не документировано] 95 | Type string `json:"type"` // [Не документировано] 96 | Value DictAttributeValue `json:"value"` // [Не документировано] 97 | ValueAsOfType any `json:"valueAsOfType"` // [Не документировано] 98 | } 99 | 100 | // DictAttributeValue - значение дополнительного атрибута элемента справочника из структуры [DictAttribute]. 101 | type DictAttributeValue struct { 102 | AsString string `json:"asString"` // [Не документировано] 103 | TypeOfValue string `json:"typeOfValue"` // [Не документировано] 104 | Value any `json:"value"` // [Не документировано] 105 | } 106 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // REST-клиент для API Госуслуг (АПИ ЕПГУ). 2 | // Разработан в соответствии с документом "Спецификация API ЕПГУ, версия 1.12" 3 | // 4 | // https://partners.gosuslugi.ru/catalog/api_for_gu 5 | // 6 | // # Методы 7 | // 8 | // - [Client.OrderCreate] — создание заявления 9 | // - [Client.OrderPushChunked] — загрузка архива по частям 10 | // - [Client.OrderPush] — формирование заявления единым методом 11 | // - [Client.OrderInfo] — запрос детальной информации по отправленному заявлению 12 | // - [Client.OrderCancel] — отмена заявления 13 | // - [Client.AttachmentDownload] — скачивание файла вложения созданного заявления 14 | // - [Client.Dict] — получение справочных данных 15 | // 16 | // # Получение маркера доступа (токена) ЕСИА 17 | // 18 | // - [github.com/ofstudio/go-api-epgu/esia/aas] — OAuth2-клиент для работы с согласиями ЕСИА 19 | // - [github.com/ofstudio/go-api-epgu/esia/signature] — Электронная подпись запросов к ЕСИА 20 | // 21 | // # Услуги API ЕПГУ 22 | // 23 | // - [github.com/ofstudio/go-api-epgu/services/sfr/10000000109-zdp] — Доставка пенсии и социальных выплат ПФР 24 | // 25 | // # Примеры 26 | // 27 | // - [github.com/ofstudio/go-api-epgu/examples/esia-token-request] — запрос согласия пользователя и получения маркера доступа 28 | // - [github.com/ofstudio/go-api-epgu/examples/esia-token-update] — обновление маркера доступа 29 | // - [github.com/ofstudio/go-api-epgu/examples/order-push-chunked] — создание заявления и загрузка архива по частям 30 | // - [github.com/ofstudio/go-api-epgu/examples/order-info] — получение детальной информации по отправленному заявлению 31 | // 32 | // # Руководящие документы 33 | // 34 | // 1. Спецификация API, основные руководящие документы и регламенты подключения опубликованы на Портале API Госуслуг: https://partners.gosuslugi.ru/catalog/api_for_gu 35 | // 2. Методические рекомендации по интеграции с REST API Цифрового профиля: https://digital.gov.ru/ru/documents/7166/ 36 | // 3. Методические рекомендации по использованию ЕСИА: https://digital.gov.ru/ru/documents/6186/ 37 | // 4. Руководство пользователя ЕСИА: https://digital.gov.ru/ru/documents/6182/ 38 | // 5. Руководство пользователя технологического портала ЕСИА: https://digital.gov.ru/ru/documents/6190/ 39 | // 40 | // # Адреса Портала Госуслуг 41 | // - Тестовая среда (SVCDEV): https://svcdev-beta.test.gosuslugi.ru 42 | // - Продуктовая среда: https://lk.gosuslugi.ru 43 | package apipgu 44 | -------------------------------------------------------------------------------- /doc/Specifikaciya_API_EPGU_v1_12_1.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ofstudio/go-api-epgu/81a542a04610583ea43cc814aa66791b9f2bcfc4/doc/Specifikaciya_API_EPGU_v1_12_1.docx -------------------------------------------------------------------------------- /dto.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | // dtoErrorResponse - ответ API ЕПГУ при ошибке 4 | // 5 | // Подробнее см. "Спецификация API ЕПГУ версия 1.12", 6 | // "Приложение 4. Ошибки, возвращаемые при запросах к API ЕПГУ" 7 | // 8 | // Пример JSON-ответа при ошибке: 9 | // 10 | // { 11 | // "code": "order_access", 12 | // "message": "У пользователя нет прав для работы с текущим заявлением" 13 | // } 14 | type dtoErrorResponse struct { 15 | Code string `json:"code"` // Код ошибки 16 | Message string `json:"message"` // Сообщение об ошибке 17 | } 18 | 19 | // dtoOrderIdResponse - ответ API ЕПГУ с номером созданного заявления. 20 | type dtoOrderIdResponse struct { 21 | OrderId int `json:"orderId"` 22 | } 23 | 24 | // dtoOrderInfoResponse - ответ API ЕПГУ с детальной информацией по отправленному заявлению. 25 | // 26 | // Подробнее см "Спецификация API ЕПГУ версия 1.12", 27 | // раздел "2.4. Получение деталей по заявлению". 28 | // 29 | // Пример для заявления "Доставка пенсии и социальных выплат СФР" (10000000109): 30 | // 31 | // { 32 | // "code": "OK", 33 | // "message": null, 34 | // "messageId": "2252fb21-92f8-61ee-a6f0-7ed53c117861", 35 | // "order": "{...}" 36 | // } 37 | type dtoOrderInfoResponse struct { 38 | Code string `json:"code"` // Код состояния заявления в соответствии с Приложением 1 Спецификации 39 | Message string `json:"message"` // Текстовое сообщение, описывающее текущее состояние запроса на создание заявления 40 | MessageId string `json:"messageId"` // [Не документировано, GUID] 41 | Order string `json:"order"` // В случае, если заявление уже создано на портале и отправлено в ведомство, параметр содержит строку в виде экранированного JSON-объекта 42 | } 43 | 44 | // dtoDictRequest - запрос к API ЕПГУ на получение справочника. 45 | // 46 | // Подробнее см. "Спецификация API ЕПГУ версия 1.12", 47 | // раздел "3. Получение справочных данных". 48 | type dtoDictRequest struct { 49 | TreeFiltering string `json:"treeFiltering"` // Тип справочника (плоский / иерархический) 50 | ParentRefItemValue string `json:"parentRefItemValue,omitempty"` // Код родительского элемента 51 | PageNum int `json:"pageNum,omitempty"` // Номер необходимой страницы 52 | PageSize int `json:"pageSize,omitempty"` // Количество записей на странице 53 | } 54 | 55 | // dtoDictResponse - ответ API ЕПГУ на запрос справочника. 56 | // 57 | // Подробнее см. "Спецификация API ЕПГУ версия 1.12", 58 | // раздел "3. Получение справочных данных". 59 | // 60 | // Пример структуры успешного ответа: 61 | // 62 | // { 63 | // "error": { 64 | // "code": 0, 65 | // "message": "operation completed" 66 | // }, 67 | // "fieldErrors": [], 68 | // "total": 1011, 69 | // "items": [...элементы справочника...] 70 | // } 71 | // 72 | // Пример структуры ответа в случае ошибки: 73 | // 74 | // { 75 | // "error": { 76 | // "code": 7, 77 | // "message": "Entity not found" 78 | // }, 79 | // "fieldErrors": [], 80 | // "total": 0, 81 | // "items": [] 82 | // } 83 | type dtoDictResponse struct { 84 | Error dtoDictResponseError `json:"error"` // Результат выполнения операции 85 | FieldErrors []map[string]interface{} `json:"fieldErrors"` // Ошибки в полях запроса 86 | Total int `json:"total"` // Общее количество найденных элементов 87 | Items []DictItem `json:"items"` // Найденные элементы справочника с учётом заданных pageNum и pageSize 88 | } 89 | 90 | // dtoDictResponseError - структура поля dtoDictResponse.Error. 91 | type dtoDictResponseError struct { 92 | Code int `json:"code"` // Код результата 93 | Message string `json:"message"` // Сообщение 94 | } 95 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | // Ошибки первого уровня. 13 | var ( 14 | ErrOrderCreate = errors.New("ошибка OrderCreate") 15 | ErrPushChunked = errors.New("ошибка OrderPushChunked") 16 | ErrPush = errors.New("ошибка OrderPush") 17 | ErrOrderInfo = errors.New("ошибка OrderInfo") 18 | ErrOrderCancel = errors.New("ошибка OrderCancel") 19 | ErrAttachmentDownload = errors.New("ошибка AttachmentDownload") 20 | ErrDict = errors.New("ошибка Dict") 21 | ErrGetOrdersStatus = errors.New("ошибка GetOrdersStatus") 22 | ErrGetUpdatedAfter = errors.New("ошибка GetUpdatedAfter") 23 | ErrService = errors.New("ошибка услуги") 24 | ) 25 | 26 | // Ошибки второго уровня. 27 | var ( 28 | ErrMultipartBody = errors.New("ошибка подготовки multipart-содержимого") 29 | ErrRequest = errors.New("ошибка HTTP-запроса") 30 | ErrUnexpectedContentType = errors.New("неожиданный тип содержимого") 31 | ErrJSONUnmarshal = errors.New("ошибка чтения JSON") 32 | ErrNoFiles = errors.New("нет файлов во вложении") 33 | ErrZip = errors.New("ошибка создания zip-архива") 34 | ErrGUID = errors.New("не удалось сгенерировать GUID") 35 | ErrXMLMarshal = errors.New("ошибка создания XML") 36 | ErrNilArchive = errors.New("не передан архив") 37 | ErrWrongOrderID = errors.New("некорректный ID заявления") 38 | ErrInvalidFileLink = errors.New("некорректная ссылка на файл") 39 | ErrDictResponse = errors.New("ошибка получения справочных данных") 40 | ) 41 | 42 | // HTTP-ошибки. 43 | // 44 | // Подробнее см. "Спецификация API ЕПГУ версия 1.12", 45 | // "Приложение 4. Ошибки, возвращаемые при запросах к API ЕПГУ" 46 | var ( 47 | ErrStatusOrderNotFound = errors.New("заявление не найдено") // HTTP 204 48 | ErrStatusBadRequest = errors.New("неверные параметры") // HTTP 400 49 | ErrStatusUnauthorized = errors.New("отказ в доступе") // HTTP 401 50 | ErrStatusForbidden = errors.New("доступ запрещен") // HTTP 403 51 | ErrStatusURLNotFound = errors.New("не найден URL запроса") // HTTP 404 52 | ErrStatusUnableToHandleRequest = errors.New("невозможно обработать запрос") // HTTP 409 53 | ErrStatusTooManyRequests = errors.New("слишком много запросов") // HTTP 429 54 | ErrStatusInternalError = errors.New("внутренняя ошибка") // HTTP 500 55 | ErrStatusBadGateway = errors.New("некорректный шлюз") // HTTP 502 56 | ErrStatusServiceUnavailable = errors.New("сервис недоступен") // HTTP 503 57 | ErrStatusGatewayTimeout = errors.New("шлюз не отвечает") // HTTP 504 58 | ErrStatusUnexpected = errors.New("неожиданный HTTP-статус") // Другие HTTP-коды ошибок 59 | ) 60 | 61 | // Ошибки ЕПГУ. 62 | // 63 | // Подробнее см. "Спецификация API ЕПГУ версия 1.12", 64 | // "Приложение 4. Ошибки, возвращаемые при запросах к API ЕПГУ" 65 | // 66 | // Пример JSON-ответа от ЕПГУ при ошибке: 67 | // 68 | // { 69 | // "code": "order_access", 70 | // "message": "У пользователя нет прав для работы с текущим заявлением" 71 | // } 72 | var ( 73 | 74 | // Ошибка ЕПГУ: code = access_denied_person_permissions 75 | ErrCodeAccessDeniedPersonPermissions = errors.New("пользователь не дал согласие Вашей системе на выполнение данной операции") 76 | 77 | // Ошибка ЕПГУ: code = access_denied_service 78 | ErrCodeAccessDeniedService = errors.New("доступ ВИС к запрашиваемой услуге запрещен") 79 | 80 | // Ошибка ЕПГУ: code = access_denied_system 81 | ErrCodeAccessDeniedSystem = errors.New("доступ запрещен для ВИС, отправляющей запрос") 82 | 83 | // Ошибка ЕПГУ: code = access_denied_user 84 | ErrCodeAccessDeniedUser = errors.New("доступ запрещен для данного типа пользователя") 85 | 86 | // Ошибка ЕПГУ: code = access_denied_user_legal 87 | ErrCodeAccessDeniedUserLegal = errors.New("попытка создать заявления с использованием токена, полученного для организации, которая не является владельцем ВИС, отправляющей данный запрос") 88 | 89 | // Ошибка ЕПГУ: code = bad_delegation 90 | ErrCodeBadDelegation = errors.New("нет необходимых полномочий для создания заявления") 91 | 92 | // Ошибка ЕПГУ: code = bad_request 93 | ErrCodeBadRequest = errors.New("ошибка в параметрах запроса") 94 | 95 | // Ошибка ЕПГУ: code = cancel_not_allowed 96 | ErrCodeCancelNotAllowed = errors.New("отмена заявления в текущем статусе невозможна") 97 | 98 | // Ошибка ЕПГУ: code = config_delegation 99 | ErrCodeConfigDelegation = errors.New("полномочие для создания и подачи заявления по заданной услуги не существует") 100 | 101 | // Ошибка ЕПГУ: code = internal_error 102 | ErrCodeInternalError = errors.New("ошибка в обработке заявления, причины которой можно выяснить при анализе инцидента") 103 | 104 | // Ошибка ЕПГУ: code = limitation_exception 105 | ErrCodeLimitationException = errors.New("превышение установленных ограничений, указанных в Приложении 3 к Спецификации") 106 | 107 | // Ошибка ЕПГУ: code = not_found 108 | ErrCodeNotFound = errors.New("заявление не найдено") 109 | 110 | // Ошибка ЕПГУ: code = order_access 111 | ErrCodeOrderAccess = errors.New("у пользователя нет прав для работы с текущим заявлением") 112 | 113 | // Ошибка ЕПГУ: code = push_denied 114 | ErrCodePushDenied = errors.New("нет прав для отправки заявления. Отправить заявление может только руководитель организации или сотрудник с доверенностью") 115 | 116 | // Ошибка ЕПГУ: code = service_not_found 117 | ErrCodeServiceNotFound = errors.New("не найдена услуга, заданная кодом serviceCode в запросе") 118 | 119 | // Ошибка ЕПГУ: неизвестное значение code 120 | ErrCodeUnexpected = errors.New("неожиданный код ошибки") 121 | 122 | // Ошибка ЕПГУ: code не указан 123 | ErrCodeNotSpecified = errors.New("код ошибки не указан") 124 | ) 125 | 126 | // responseError возвращает ошибку из HTTP-ответа от API ЕПГУ. 127 | // Пример сообщения об ошибке: 128 | // 129 | // HTTP 403 Forbidden: доступ запрещен: доступ запрещен для ВИС, отправляющей запрос [code='access_denied_system', message='ValidationCommonError.notAllowed'] 130 | func responseError(res *http.Response) error { 131 | if res == nil || (res.StatusCode != 204 && res.StatusCode < 400) { 132 | return nil 133 | } 134 | 135 | switch res.StatusCode { 136 | case 400, 403, 409, 500: 137 | return fmt.Errorf("HTTP %s: %w: %w", res.Status, httpStatusError(res.StatusCode), bodyError(res)) 138 | default: 139 | return fmt.Errorf("HTTP %s: %w", res.Status, httpStatusError(res.StatusCode)) 140 | } 141 | } 142 | 143 | func httpStatusError(statusCode int) error { 144 | switch statusCode { 145 | case 204: 146 | return ErrStatusOrderNotFound 147 | case 400: 148 | return ErrStatusBadRequest 149 | case 401: 150 | return ErrStatusUnauthorized 151 | case 403: 152 | return ErrStatusForbidden 153 | case 404: 154 | return ErrStatusURLNotFound 155 | case 409: 156 | return ErrStatusUnableToHandleRequest 157 | case 429: 158 | return ErrStatusTooManyRequests 159 | case 500: 160 | return ErrStatusInternalError 161 | case 502: 162 | return ErrStatusBadGateway 163 | case 503: 164 | return ErrStatusServiceUnavailable 165 | case 504: 166 | return ErrStatusGatewayTimeout 167 | default: 168 | return ErrStatusUnexpected 169 | } 170 | } 171 | 172 | func bodyError(res *http.Response) error { 173 | body, err := io.ReadAll(res.Body) 174 | if err != nil { 175 | return fmt.Errorf("%w: %w", ErrRequest, err) 176 | } 177 | ct := res.Header.Get("Content-Type") 178 | if strings.HasPrefix(ct, "application/json") { 179 | return jsonError(body) 180 | } 181 | return fmt.Errorf("%w: '%s'", ErrUnexpectedContentType, ct) 182 | } 183 | 184 | func jsonError(body []byte) error { 185 | errResponse := &dtoErrorResponse{} 186 | err := json.Unmarshal(body, errResponse) 187 | if err != nil { 188 | return fmt.Errorf("%w: %w", ErrJSONUnmarshal, err) 189 | } 190 | 191 | switch errResponse.Code { 192 | case "access_denied_person_permissions": 193 | err = ErrCodeAccessDeniedPersonPermissions 194 | case "access_denied_service": 195 | err = ErrCodeAccessDeniedService 196 | case "access_denied_system": 197 | err = ErrCodeAccessDeniedSystem 198 | case "access_denied_user": 199 | err = ErrCodeAccessDeniedUser 200 | case "access_denied_user_legal": 201 | err = ErrCodeAccessDeniedUserLegal 202 | case "bad_delegation": 203 | err = ErrCodeBadDelegation 204 | case "bad_request": 205 | err = ErrCodeBadRequest 206 | case "cancel_not_allowed": 207 | err = ErrCodeCancelNotAllowed 208 | case "config_delegation": 209 | err = ErrCodeConfigDelegation 210 | case "internal_error": 211 | err = ErrCodeInternalError 212 | case "limitation_exception": 213 | err = ErrCodeLimitationException 214 | case "not_found": 215 | err = ErrCodeNotFound 216 | case "order_access": 217 | err = ErrCodeOrderAccess 218 | case "push_denied": 219 | err = ErrCodePushDenied 220 | case "service_not_found": 221 | err = ErrCodeServiceNotFound 222 | case "": 223 | err = ErrCodeNotSpecified 224 | default: 225 | err = ErrCodeUnexpected 226 | } 227 | 228 | return fmt.Errorf("%w [code='%s', message='%s']", err, errResponse.Code, errResponse.Message) 229 | } 230 | 231 | func dictError(dictResponseError dtoDictResponseError) error { 232 | if dictResponseError.Code == 0 { 233 | return nil 234 | } 235 | return fmt.Errorf("%w [code='%d', message='%s']", ErrDictResponse, dictResponseError.Code, dictResponseError.Message) 236 | } 237 | -------------------------------------------------------------------------------- /esia/aas/README.md: -------------------------------------------------------------------------------- 1 | # go-api-epgu/esia/aas 2 | 3 | OAuth2-клиент для запроса согласия и маркера доступа ЕСИА 4 | для получателей услуг ЕПГУ — физических лиц. 5 | 6 | ## Методы 7 | 8 | - [Client.AuthURI](https://pkg.go.dev/github.com/ofstudio/go-api-epgu/esia/aas/#Client.AuthURI) — формирует ссылку на страницу ЕСИА для предоставления пользователем запрошенных прав 9 | - [Client.ParseCallback](https://pkg.go.dev/github.com/ofstudio/go-api-epgu/esia/aas/#Client.ParseCallback) — возвращает код авторизации из callback-запроса к `redirect_uri` 10 | - [Client.TokenExchange](https://pkg.go.dev/github.com/ofstudio/go-api-epgu/esia/aas/#Client.TokenExchange) — обменивает код авторизации на маркер доступа (токен) 11 | - [Client.TokenUpdate](https://pkg.go.dev/github.com/ofstudio/go-api-epgu/esia/aas/#Client.TokenUpdate) — обновляет маркер доступа по идентификатору пользователя (OID) 12 | 13 | ## Примеры 14 | - [Запрос согласия пользователя и получения маркера доступа](/examples/esia-token-request/main.go) 15 | - [Обновление маркера доступа](/examples/esia-token-update/main.go) 16 | 17 | ## Системные требования 18 | 19 | - Go 1.21+ 20 | - Для подписания запросов к ЕСИА с помощью 21 | [LocalCryptoPro](https://pkg.go.dev/github.com/ofstudio/go-api-epgu/esia/signature#LocalCryptoPro) — 22 | КриптоПро CSP 5.0+ и сертификат для подписания запросов 23 | 24 | ## Регламентные требования 25 | 1. Информационная система (ИС) должна быть зарегистрирована на 26 | Технологическом портале ЕСИА: продуктовом или тестовом (SVCDEV) 27 | 2. Для ИС должен быть выпущен необходимый сертификат 28 | 3. Публичная часть сертификата должна быть загружена на Технологический портал ЕСИА 29 | 4. Выполнены все необходимые шаги регламента подключения ИС к тестовой 30 | или продуктовой среде ЕСИА и согласована заявка на доступ ИС к необходимым скоупам 31 | 32 | ## Ссылки 33 | 34 | ### Руководящие документы 35 | 1. [Методические рекомендации по интеграции с REST API Цифрового профиля](https://digital.gov.ru/ru/documents/7166/) 36 | 2. [Методические рекомендации по использованию ЕСИА](https://digital.gov.ru/ru/documents/6186/) 37 | 3. [Руководство пользователя ЕСИА](https://digital.gov.ru/ru/documents/6182/) 38 | 4. [Руководство пользователя технологического портала ЕСИА](https://digital.gov.ru/ru/documents/6190/) 39 | 40 | ### Адреса Технологического портала ЕСИА 41 | - Тестовая среда (SVCDEV): https://esia-portal1.test.gosuslugi.ru/console/tech 42 | - Продуктовая среда: https://esia.gosuslugi.ru/console/tech/ 43 | 44 | ### Адреса Портала Госуслуг 45 | - Тестовая среда (SVCDEV): https://svcdev-beta.test.gosuslugi.ru 46 | - Продуктовая среда: https://lk.gosuslugi.ru 47 | 48 | ### Страница предоставленных согласий пользователя на Портале Госуслуг 49 | - Тестовая среда (SVCDEV): https://svcdev-betalk.test.gosuslugi.ru/settings/third-party/agreements/acting 50 | - Продуктовая среда: https://lk.gosuslugi.ru/settings/third-party/agreements/acting -------------------------------------------------------------------------------- /esia/aas/client.go: -------------------------------------------------------------------------------- 1 | package aas 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | "time" 10 | 11 | "github.com/ofstudio/go-api-epgu/esia/signature" 12 | "github.com/ofstudio/go-api-epgu/utils" 13 | ) 14 | 15 | const tsLayout = "2006.01.02 15:04:05 -0700" 16 | 17 | const ( 18 | UserEndpoint = "/aas/oauth2/v2/ac" // URI страницы ЕСИА для предоставления пользователем запрошенных прав 19 | TokenEndpoint = "/aas/oauth2/v3/te" // Эндпоинт для обмена кода авторизации на маркер доступа 20 | ) 21 | 22 | // ErrorResponse - ответ от ЕСИА при ошибке 23 | type ErrorResponse struct { 24 | Error string `json:"error"` 25 | ErrorDescription string `json:"error_description"` 26 | State string `json:"state"` 27 | } 28 | 29 | // TokenExchangeResponse - ответ от ЕСИА при успешном обмене кода на маркер доступа 30 | type TokenExchangeResponse struct { 31 | AccessToken string `json:"access_token"` 32 | IdToken string `json:"id_token"` 33 | State string `json:"state"` 34 | TokenType string `json:"token_type"` 35 | ExpiresIn int `json:"expires_in"` 36 | } 37 | 38 | // Client - OAuth2-клиент для запроса согласия и маркера доступа ЕСИА 39 | // для получателей услуг ЕПГУ - физических лиц. 40 | type Client struct { 41 | baseURI string 42 | clientId string 43 | signer signature.Provider 44 | httpClient *http.Client 45 | logger utils.Logger 46 | debug bool 47 | } 48 | 49 | // NewClient - конструктор для Client. 50 | // Параметры: 51 | // - baseURI - URI ЕСИА 52 | // - clientId - мнемоника ИС-потребителя 53 | // - signer - провайдер подписи запросов 54 | func NewClient(baseURI, clientId string, signer signature.Provider) *Client { 55 | return &Client{ 56 | baseURI: baseURI, 57 | clientId: clientId, 58 | signer: signer, 59 | httpClient: &http.Client{}, 60 | } 61 | } 62 | 63 | // WithDebug - включает логирование запросов и ответов 64 | // Формат лога: 65 | // 66 | // >>> Request to {url} 67 | // ... 68 | // {полный HTTP-запрос} 69 | // ... 70 | // <<< Response from {url} 71 | // ... 72 | // {полный HTTP-ответ} 73 | // ... 74 | func (c *Client) WithDebug(logger utils.Logger) *Client { 75 | c.logger = logger 76 | c.debug = logger != nil 77 | return c 78 | } 79 | 80 | // WithHTTPClient - устанавливает http-клиент для запросов к ЕСИА 81 | func (c *Client) WithHTTPClient(httpClient *http.Client) *Client { 82 | if httpClient != nil { 83 | c.httpClient = httpClient 84 | } 85 | return c 86 | } 87 | 88 | // AuthURI - формирует URI на страницу ЕСИА для предоставления пользователем запрошенных прав. 89 | // Тк используется параметр [Permissions], то в scope необходимо указывать "openid". 90 | // 91 | // Возвращает URI на страницу ЕСИА либо цепочку ошибок из [ErrAuthURI] и других: 92 | // - [ErrSign] - ошибка подписи ссылки 93 | // - [ErrGUID] - при невозможности сформировать GUID 94 | // 95 | // Подробнее см "Методические рекомендации по использованию ЕСИА", 96 | // раздел "Получение авторизационного кода (v2/ac)". 97 | func (c *Client) AuthURI(scope, redirectURI string, permissions Permissions) (string, error) { 98 | timestamp := time.Now().UTC().Format(tsLayout) 99 | state, err := guid() 100 | if err != nil { 101 | return "", fmt.Errorf("%w: %w: %w", ErrAuthURI, ErrGUID, err) 102 | } 103 | clientSecret, err := c.sign(c.clientId, scope, timestamp, state, redirectURI) 104 | if err != nil { 105 | return "", fmt.Errorf("%w: %w", ErrAuthURI, err) 106 | } 107 | 108 | params := &url.Values{} 109 | params.Add("client_id", c.clientId) 110 | params.Add("client_secret", clientSecret) 111 | params.Add("scope", scope) 112 | params.Add("timestamp", timestamp) 113 | params.Add("state", state) 114 | params.Add("redirect_uri", redirectURI) 115 | params.Add("client_certificate_hash", c.signer.CertHash()) 116 | params.Add("response_type", "code") 117 | params.Add("access_type", "online") 118 | params.Add("permissions", permissions.Base64String()) 119 | 120 | return c.baseURI + UserEndpoint + "?" + params.Encode(), nil 121 | } 122 | 123 | // ParseCallback - возвращает код авторизации code и state из 124 | // query-параметров callback-запроса к redirect_uri от ЕСИА. 125 | // 126 | // Подробнее см "Методические рекомендации по использованию ЕСИА", 127 | // раздел "Получение авторизационного кода (v2/ac)". 128 | // 129 | // В случае ошибки возвращает цепочку из [ErrParseCallback] и других: 130 | // - [ErrNoState] - отсутствует параметр state 131 | // - [ErrParseCallback] - ошибка разбора параметров 132 | // - ошибка ЕСИА: ErrESIAxxxxxx ([ErrESIA_007003] и др.) 133 | // 134 | // Пример сообщения об ошибке: 135 | // 136 | // ESIA-007014: Запрос не содержит обязательного параметра [error='invalid_request', error_description='ESIA-007014: The request does not contain the mandatory parameter' state='48d1a8dc-0b7d-418a-b4ef-2c7797f77dc9']' 137 | func (c *Client) ParseCallback(query url.Values) (string, string, error) { 138 | state := query.Get("state") 139 | if state == "" { 140 | return "", "", fmt.Errorf("%w: %w", ErrParseCallback, ErrNoState) 141 | } 142 | 143 | code := query.Get("code") 144 | if code == "" { 145 | return "", state, fmt.Errorf("%w: %w", ErrParseCallback, callbackError(query)) 146 | } 147 | 148 | return code, state, nil 149 | } 150 | 151 | // TokenExchange обменивает код авторизации на маркер доступа. 152 | // Параметры scope и redirectURI должны быть такими же, как и при вызове [Client.AuthURI]. 153 | // 154 | // Подробнее см "Методические рекомендации по использованию ЕСИА", 155 | // раздел "Получение маркера доступа в обмен на авторизационный код (v3/te)". 156 | // 157 | // Возвращает ответ от ЕСИА [TokenExchangeResponse] либо цепочку ошибок из [ErrTokenExchange] и других: 158 | // - [ErrSign] - ошибка подписи запроса 159 | // - [ErrGUID] - при невозможности сформировать GUID 160 | // - [ErrRequest] - ошибка HTTP-запроса 161 | // - [ErrJSONUnmarshal] - ошибка разбора ответа 162 | // - [ErrUnexpectedContentType] - неожидаемый Content-Type ответа 163 | // - ошибок ЕСИА ErrESIA_xxxxxx ([ErrESIA_007004] и др.) 164 | // 165 | // Пример сообщения об ошибке: 166 | // 167 | // HTTP 400 Bad request: ESIA-007014: Запрос не содержит обязательного параметра [error='invalid_request', error_description='ESIA-007014: The request does not contain the mandatory parameter' state='48d1a8dc-0b7d-418a-b4ef-2c7797f77dc9']' 168 | func (c *Client) TokenExchange(code, scope, redirectURI string) (*TokenExchangeResponse, error) { 169 | timestamp := time.Now().UTC().Format(tsLayout) 170 | state, err := guid() 171 | if err != nil { 172 | return nil, fmt.Errorf("%w: %w: %w", ErrTokenExchange, ErrGUID, err) 173 | } 174 | clientSecret, err := c.sign(c.clientId, scope, timestamp, state, redirectURI, code) 175 | if err != nil { 176 | return nil, fmt.Errorf("%w: %w", ErrTokenExchange, err) 177 | } 178 | 179 | reqBody := url.Values{} 180 | reqBody.Set("client_id", c.clientId) 181 | reqBody.Set("client_secret", clientSecret) 182 | reqBody.Set("scope", scope) 183 | reqBody.Set("timestamp", timestamp) 184 | reqBody.Set("state", state) 185 | reqBody.Set("redirect_uri", redirectURI) 186 | reqBody.Set("client_certificate_hash", c.signer.CertHash()) 187 | reqBody.Set("code", code) 188 | reqBody.Set("grant_type", "authorization_code") 189 | reqBody.Set("token_type", "Bearer") 190 | 191 | result := &TokenExchangeResponse{} 192 | 193 | if err = c.request( 194 | http.MethodPost, 195 | TokenEndpoint, 196 | "application/x-www-form-urlencoded", 197 | strings.NewReader(reqBody.Encode()), 198 | result, 199 | ); err != nil { 200 | return nil, fmt.Errorf("%w: %w", ErrTokenExchange, err) 201 | } 202 | 203 | return result, nil 204 | } 205 | 206 | // TokenUpdate обновляет маркер доступа по идентификатору пользователя (OID), 207 | // используя scope="prm_chg". Параметр redirectURI должен быть таким же, как и при вызове AuthURI. 208 | // 209 | // Подробнее см "Методические рекомендации по интеграции с REST API Цифрового профиля" 210 | // раздел "Online-режим запроса согласий". 211 | // 212 | // Возвращает ответ от ЕСИА [TokenExchangeResponse] либо цепочку ошибок из [ErrTokenUpdate] и 213 | // ошибок аналогичных TokenExchange. 214 | func (c *Client) TokenUpdate(oid, redirectURI string) (*TokenExchangeResponse, error) { 215 | timestamp := time.Now().UTC().Format(tsLayout) 216 | scope := "prm_chg?oid=" + oid 217 | state, err := guid() 218 | if err != nil { 219 | return nil, fmt.Errorf("%w: %w: %w", ErrTokenUpdate, ErrGUID, err) 220 | } 221 | clientSecret, err := c.sign(c.clientId, scope, timestamp, state, redirectURI) 222 | if err != nil { 223 | return nil, fmt.Errorf("%w: %w", ErrTokenUpdate, err) 224 | } 225 | 226 | reqBody := url.Values{} 227 | reqBody.Set("client_id", c.clientId) 228 | reqBody.Set("client_secret", clientSecret) 229 | reqBody.Set("scope", scope) 230 | reqBody.Set("timestamp", timestamp) 231 | reqBody.Set("state", state) 232 | reqBody.Set("redirect_uri", redirectURI) 233 | reqBody.Set("client_certificate_hash", c.signer.CertHash()) 234 | reqBody.Set("grant_type", "client_credentials") 235 | reqBody.Set("token_type", "Bearer") 236 | 237 | result := &TokenExchangeResponse{} 238 | if err = c.request( 239 | http.MethodPost, 240 | TokenEndpoint, 241 | "application/x-www-form-urlencoded", 242 | strings.NewReader(reqBody.Encode()), 243 | result, 244 | ); err != nil { 245 | return nil, fmt.Errorf("%w: %w", ErrTokenUpdate, err) 246 | } 247 | return result, nil 248 | } 249 | 250 | func (c *Client) sign(args ...string) (string, error) { 251 | if c.signer == nil { 252 | return "", fmt.Errorf("%w: signer not specified", ErrSign) 253 | } 254 | data := []byte(strings.Join(args, "")) 255 | sign, err := c.signer.Sign(data) 256 | if err != nil { 257 | return "", fmt.Errorf("%w: %w", ErrSign, err) 258 | } 259 | return base64.URLEncoding.EncodeToString(sign), nil 260 | } 261 | 262 | func (c *Client) logReq(req *http.Request) { 263 | if c.debug { 264 | utils.LogReq(req, c.logger) 265 | } 266 | } 267 | 268 | func (c *Client) logRes(res *http.Response) { 269 | if c.debug { 270 | utils.LogRes(res, c.logger) 271 | } 272 | } 273 | 274 | var guid = utils.GUID 275 | -------------------------------------------------------------------------------- /esia/aas/client_test.go: -------------------------------------------------------------------------------- 1 | package aas 2 | 3 | import ( 4 | "encoding/base64" 5 | "errors" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/suite" 12 | 13 | "github.com/ofstudio/go-api-epgu/esia/signature" 14 | "github.com/ofstudio/go-api-epgu/utils" 15 | ) 16 | 17 | const ( 18 | testSignature = "this is a test signature" 19 | testCertHash = "test_hash" 20 | ) 21 | 22 | type suiteTestClient struct { 23 | suite.Suite 24 | } 25 | 26 | func TestClient(t *testing.T) { 27 | suite.Run(t, new(suiteTestClient)) 28 | } 29 | 30 | func (suite *suiteTestClient) TearDownSubTest() { 31 | guid = utils.GUID 32 | } 33 | 34 | func (suite *suiteTestClient) TestAuthURI() { 35 | 36 | suite.Run("success", func() { 37 | guid = func() (string, error) { 38 | return "test-state", nil 39 | } 40 | 41 | client := NewClient("", "test-client", signature.NewNop(testSignature, testCertHash)) 42 | var permissions = Permissions{ 43 | { 44 | ResponsibleObject: "test", 45 | Sysname: "test", 46 | Expire: 1, 47 | Actions: []PermissionAction{{Sysname: "test"}}, 48 | Purposes: []PermissionPurpose{{Sysname: "test"}}, 49 | Scopes: []PermissionScope{{Sysname: "test"}}, 50 | }, 51 | } 52 | 53 | uriStr, err := client.AuthURI("test-scope", "test-redirect", permissions) 54 | suite.NoError(err) 55 | u, err := url.Parse(uriStr) 56 | suite.NoError(err) 57 | 58 | q := u.Query() 59 | suite.Equal(UserEndpoint, u.Path) 60 | suite.Equal("test-client", q.Get("client_id")) 61 | suite.Equal("test-scope", q.Get("scope")) 62 | suite.Equal("test-state", q.Get("state")) 63 | suite.Equal("test-redirect", q.Get("redirect_uri")) 64 | suite.Equal("code", q.Get("response_type")) 65 | suite.Equal(permissions.Base64String(), q.Get("permissions")) 66 | suite.Equal(testCertHash, q.Get("client_certificate_hash")) 67 | suite.Regexp(`^\d{4}.\d{2}.\d{2} \d{2}:\d{2}:\d{2} [\+-]\d{4}$`, q.Get("timestamp")) // timestamp 68 | 69 | sign, err := base64.URLEncoding.DecodeString(q.Get("client_secret")) 70 | suite.NoError(err) 71 | suite.Equal(testSignature, string(sign)) 72 | suite.Equal("online", q.Get("access_type")) 73 | }) 74 | 75 | suite.Run("error guid", func() { 76 | client := NewClient("", "test-client", signature.NewNop(testSignature, testCertHash)) 77 | guid = func() (string, error) { 78 | return "", ErrGUID 79 | } 80 | uriStr, err := client.AuthURI("test-scope", "test-redirect", Permissions{}) 81 | suite.ErrorIs(err, ErrAuthURI) 82 | suite.ErrorIs(err, ErrGUID) 83 | suite.Empty(uriStr) 84 | }) 85 | 86 | suite.Run("error sign", func() { 87 | client := NewClient("", "test", signature.NewNop("", "")) 88 | uriStr, err := client.AuthURI("openid", "test", Permissions{}) 89 | suite.ErrorIs(err, ErrAuthURI) 90 | suite.ErrorIs(err, ErrSign) 91 | suite.Empty(uriStr) 92 | }) 93 | 94 | suite.Run("error signer is nil", func() { 95 | client := NewClient("", "test", nil) 96 | uriStr, err := client.AuthURI("openid", "test", Permissions{}) 97 | suite.ErrorIs(err, ErrAuthURI) 98 | suite.ErrorIs(err, ErrSign) 99 | suite.Empty(uriStr) 100 | }) 101 | 102 | } 103 | 104 | func (suite *suiteTestClient) TestParseCallback() { 105 | suite.Run("success", func() { 106 | client := NewClient("", "test", signature.NewNop(testSignature, testCertHash)) 107 | code, state, err := client.ParseCallback(url.Values{ 108 | "code": []string{"test-code"}, 109 | "state": []string{"test-state"}, 110 | }) 111 | suite.NoError(err) 112 | suite.Equal("test-code", code) 113 | suite.Equal("test-state", state) 114 | }) 115 | 116 | suite.Run("error no state", func() { 117 | client := NewClient("", "test", signature.NewNop(testSignature, testCertHash)) 118 | code, state, err := client.ParseCallback(url.Values{ 119 | "code": []string{"ESIA-007014: The request doesn't contain..."}, 120 | }) 121 | suite.ErrorIs(err, ErrParseCallback) 122 | suite.ErrorIs(err, ErrNoState) 123 | suite.Equal("ошибка обратного вызова: отсутствует поле state", err.Error()) 124 | suite.Empty(code) 125 | suite.Empty(state) 126 | }) 127 | 128 | suite.Run("error ESIA", func() { 129 | client := NewClient("", "test", signature.NewNop(testSignature, testCertHash)) 130 | code, state, err := client.ParseCallback(url.Values{ 131 | "state": []string{"test"}, 132 | "error": []string{"invalid_request"}, 133 | "error_description": []string{"ESIA-007014: The request doesn't contain..."}, 134 | }) 135 | suite.ErrorIs(err, ErrParseCallback) 136 | suite.ErrorIs(err, ErrESIA_007014) 137 | suite.Equal( 138 | "ошибка обратного вызова: ESIA-007014: Запрос не содержит обязательного параметра [error='invalid_request', error_description='ESIA-007014: The request doesn't contain...', state='test']", 139 | err.Error(), 140 | ) 141 | suite.Empty(code) 142 | suite.Equal("test", state) 143 | }) 144 | 145 | suite.Run("error ESIA unknown", func() { 146 | client := NewClient("", "test", signature.NewNop(testSignature, testCertHash)) 147 | code, state, err := client.ParseCallback(url.Values{ 148 | "state": []string{"test"}, 149 | "error": []string{"invalid_request"}, 150 | "error_description": []string{"ESIA-999999: The request doesn't contain..."}, 151 | }) 152 | 153 | suite.ErrorIs(err, ErrParseCallback) 154 | suite.ErrorIs(err, ErrESIA_unknown) 155 | suite.Equal( 156 | "ошибка обратного вызова: неизвестная ошибка ЕСИА [error='invalid_request', error_description='ESIA-999999: The request doesn't contain...', state='test']", 157 | err.Error(), 158 | ) 159 | suite.Empty(code) 160 | suite.Equal("test", state) 161 | }) 162 | } 163 | 164 | func (suite *suiteTestClient) TestTokenExchange() { 165 | suite.Run("success", func() { 166 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 167 | suite.Equal(http.MethodPost, r.Method) 168 | suite.Equal("/aas/oauth2/v3/te", r.URL.Path) 169 | suite.Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type")) 170 | suite.Equal("test", r.FormValue("client_id")) 171 | suite.Equal(base64.URLEncoding.EncodeToString([]byte(testSignature)), r.FormValue("client_secret")) 172 | suite.Equal("test-code", r.FormValue("code")) 173 | suite.Equal("test-scope", r.FormValue("scope")) 174 | suite.Equal(testCertHash, r.FormValue("client_certificate_hash")) 175 | suite.Equal("test-uri", r.FormValue("redirect_uri")) 176 | suite.Equal("authorization_code", r.FormValue("grant_type")) 177 | suite.Equal("Bearer", r.FormValue("token_type")) 178 | suite.Regexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$`, r.FormValue("state")) // guid 179 | suite.Regexp(`^\d{4}.\d{2}.\d{2} \d{2}:\d{2}:\d{2} [\+-]\d{4}$`, r.FormValue("timestamp")) // timestamp 180 | 181 | w.WriteHeader(http.StatusOK) 182 | _, _ = w.Write([]byte(`{"access_token":"test","id_token":"test","state":"test","token_type":"Bearer","expires_in":0}`)) 183 | })) 184 | defer server.Close() 185 | 186 | client := NewClient(server.URL, "test", signature.NewNop(testSignature, testCertHash)) 187 | token, err := client.TokenExchange("test-code", "test-scope", "test-uri") 188 | suite.NoError(err) 189 | suite.Require().NotNil(token) 190 | suite.Equal("test", token.AccessToken) 191 | }) 192 | 193 | suite.Run("error 500 unexpected content type", func() { 194 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 195 | w.Header().Set("Content-Type", "text/html") 196 | w.WriteHeader(http.StatusInternalServerError) 197 | _, _ = w.Write([]byte("test")) 198 | })) 199 | defer server.Close() 200 | 201 | client := NewClient(server.URL, "test", signature.NewNop(testSignature, testCertHash)) 202 | token, err := client.TokenExchange("test", "test", "test") 203 | suite.ErrorIs(err, ErrTokenExchange) 204 | suite.ErrorIs(err, ErrUnexpectedContentType) 205 | suite.Equal( 206 | "ошибка запроса токена: HTTP 500 Internal Server Error: неожиданный тип содержимого: 'text/html'", 207 | err.Error(), 208 | ) 209 | suite.Nil(token) 210 | }) 211 | 212 | suite.Run("error 400 ESIA-007004", func() { 213 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 214 | w.Header().Set("Content-Type", "application/json") 215 | w.WriteHeader(http.StatusBadRequest) 216 | _, _ = w.Write([]byte(`{"error":"access_denied","error_description":"ESIA-007004: Владелец ресурса или сервис авторизации отклонил запрос","state":"test"}`)) 217 | })) 218 | defer server.Close() 219 | 220 | client := NewClient(server.URL, "test", signature.NewNop(testSignature, testCertHash)) 221 | token, err := client.TokenExchange("test", "test", "test") 222 | suite.ErrorIs(err, ErrTokenExchange) 223 | suite.ErrorIs(err, ErrESIA_007004) 224 | suite.Equal( 225 | "ошибка запроса токена: HTTP 400 Bad Request: ESIA-007004: Владелец ресурса или сервис авторизации отклонил запрос [error='access_denied', error_description='ESIA-007004: Владелец ресурса или сервис авторизации отклонил запрос', state='test']", 226 | err.Error(), 227 | ) 228 | suite.Nil(token) 229 | }) 230 | 231 | suite.Run("error 200 malformed json", func() { 232 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 233 | w.Header().Set("Content-Type", "application/json") 234 | w.WriteHeader(http.StatusOK) 235 | _, _ = w.Write([]byte("not a json")) 236 | })) 237 | defer server.Close() 238 | 239 | client := NewClient(server.URL, "test", signature.NewNop(testSignature, testCertHash)) 240 | token, err := client.TokenExchange("test", "test", "test") 241 | suite.ErrorIs(err, ErrTokenExchange) 242 | suite.ErrorIs(err, ErrJSONUnmarshal) 243 | suite.Equal( 244 | "ошибка запроса токена: ошибка чтения JSON: invalid character 'o' in literal null (expecting 'u')", 245 | err.Error(), 246 | ) 247 | suite.Nil(token) 248 | }) 249 | 250 | suite.Run("error 400 malformed json", func() { 251 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 252 | w.Header().Set("Content-Type", "application/json") 253 | w.WriteHeader(http.StatusBadRequest) 254 | _, _ = w.Write([]byte("not a json")) 255 | })) 256 | defer server.Close() 257 | 258 | client := NewClient(server.URL, "test", signature.NewNop(testSignature, testCertHash)) 259 | token, err := client.TokenExchange("test", "test", "test") 260 | suite.ErrorIs(err, ErrTokenExchange) 261 | suite.ErrorIs(err, ErrJSONUnmarshal) 262 | suite.Equal( 263 | "ошибка запроса токена: HTTP 400 Bad Request: ошибка чтения JSON: invalid character 'o' in literal null (expecting 'u')", 264 | err.Error(), 265 | ) 266 | suite.Nil(token) 267 | }) 268 | 269 | suite.Run("error guid", func() { 270 | guid = func() (string, error) { 271 | return "", errors.New("test") 272 | } 273 | 274 | client := NewClient("", "test", signature.NewNop(testSignature, testCertHash)) 275 | token, err := client.TokenExchange("test", "test", "test") 276 | suite.ErrorIs(err, ErrTokenExchange) 277 | suite.ErrorIs(err, ErrGUID) 278 | suite.Nil(token) 279 | }) 280 | 281 | suite.Run("error request call", func() { 282 | client := NewClient("", "test", signature.NewNop(testSignature, testCertHash)) 283 | token, err := client.TokenExchange("test", "test", "test") 284 | suite.ErrorIs(err, ErrTokenExchange) 285 | suite.ErrorIs(err, ErrRequest) 286 | suite.Nil(token) 287 | }) 288 | 289 | suite.Run("error sign", func() { 290 | client := NewClient("", "test", signature.NewNop("", "")) 291 | token, err := client.TokenExchange("test", "test", "test") 292 | suite.ErrorIs(err, ErrTokenExchange) 293 | suite.ErrorIs(err, ErrSign) 294 | suite.Nil(token) 295 | }) 296 | 297 | suite.Run("error signer is nil", func() { 298 | client := NewClient("", "test", nil) 299 | token, err := client.TokenExchange("test", "test", "test") 300 | suite.ErrorIs(err, ErrTokenExchange) 301 | suite.ErrorIs(err, ErrSign) 302 | suite.Nil(token) 303 | }) 304 | } 305 | 306 | func (suite *suiteTestClient) TestTokenUpdate() { 307 | 308 | suite.Run("success", func() { 309 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 310 | suite.Equal(http.MethodPost, r.Method) 311 | suite.Equal("/aas/oauth2/v3/te", r.URL.Path) 312 | suite.Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type")) 313 | suite.Equal("test-client", r.FormValue("client_id")) 314 | suite.Equal(base64.URLEncoding.EncodeToString([]byte(testSignature)), r.FormValue("client_secret")) 315 | suite.Equal("prm_chg?oid=test-oid", r.FormValue("scope")) 316 | suite.Equal(testCertHash, r.FormValue("client_certificate_hash")) 317 | suite.Equal("test-redirect", r.FormValue("redirect_uri")) 318 | suite.Equal("client_credentials", r.FormValue("grant_type")) 319 | suite.Equal("Bearer", r.FormValue("token_type")) 320 | suite.Regexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$`, r.FormValue("state")) // guid 321 | suite.Regexp(`^\d{4}.\d{2}.\d{2} \d{2}:\d{2}:\d{2} [\+-]\d{4}$`, r.FormValue("timestamp")) // timestamp 322 | 323 | w.WriteHeader(http.StatusOK) 324 | _, _ = w.Write([]byte(`{"access_token":"test","id_token":"test","state":"test","token_type":"Bearer","expires_in":0}`)) 325 | })) 326 | defer server.Close() 327 | 328 | client := NewClient(server.URL, "test-client", signature.NewNop(testSignature, testCertHash)) 329 | token, err := client.TokenUpdate("test-oid", "test-redirect") 330 | suite.NoError(err) 331 | suite.Require().NotNil(token) 332 | suite.Equal("test", token.AccessToken) 333 | }) 334 | 335 | suite.Run("error 500 unexpected content type", func() { 336 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 337 | w.Header().Set("Content-Type", "text/html") 338 | w.WriteHeader(http.StatusInternalServerError) 339 | _, _ = w.Write([]byte("test")) 340 | })) 341 | defer server.Close() 342 | 343 | client := NewClient(server.URL, "test", signature.NewNop(testSignature, testCertHash)) 344 | token, err := client.TokenUpdate("test", "test") 345 | suite.ErrorIs(err, ErrTokenUpdate) 346 | suite.ErrorIs(err, ErrUnexpectedContentType) 347 | suite.Nil(token) 348 | }) 349 | 350 | suite.Run("error 400 ESIA-007004", func() { 351 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 352 | w.Header().Set("Content-Type", "application/json") 353 | w.WriteHeader(http.StatusBadRequest) 354 | _, _ = w.Write([]byte(`{"error":"access_denied","error_description":"ESIA-007004: Владелец ресурса или сервис авторизации отклонил запрос","state":"test"}`)) 355 | })) 356 | defer server.Close() 357 | 358 | client := NewClient(server.URL, "test-client", signature.NewNop(testSignature, testCertHash)) 359 | token, err := client.TokenUpdate("test-oid", "test-redirect") 360 | suite.ErrorIs(err, ErrTokenUpdate) 361 | suite.ErrorIs(err, ErrESIA_007004) 362 | suite.Equal( 363 | "ошибка обновления токена: HTTP 400 Bad Request: ESIA-007004: Владелец ресурса или сервис авторизации отклонил запрос [error='access_denied', error_description='ESIA-007004: Владелец ресурса или сервис авторизации отклонил запрос', state='test']", 364 | err.Error(), 365 | ) 366 | suite.Nil(token) 367 | }) 368 | 369 | suite.Run("error malformed json", func() { 370 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 371 | w.Header().Set("Content-Type", "application/json") 372 | w.WriteHeader(http.StatusOK) 373 | _, _ = w.Write([]byte("not a json")) 374 | })) 375 | defer server.Close() 376 | 377 | client := NewClient(server.URL, "test-client", signature.NewNop(testSignature, testCertHash)) 378 | token, err := client.TokenUpdate("test-oid", "test-redirect") 379 | suite.ErrorIs(err, ErrTokenUpdate) 380 | suite.ErrorIs(err, ErrJSONUnmarshal) 381 | suite.Equal( 382 | "ошибка обновления токена: ошибка чтения JSON: invalid character 'o' in literal null (expecting 'u')", 383 | err.Error(), 384 | ) 385 | suite.Nil(token) 386 | }) 387 | 388 | suite.Run("error guid", func() { 389 | guid = func() (string, error) { 390 | return "", errors.New("test") 391 | } 392 | 393 | client := NewClient("", "test-client", signature.NewNop(testSignature, testCertHash)) 394 | token, err := client.TokenUpdate("test-oid", "test-redirect") 395 | suite.ErrorIs(err, ErrTokenUpdate) 396 | suite.ErrorIs(err, ErrGUID) 397 | suite.Nil(token) 398 | }) 399 | 400 | suite.Run("error request call", func() { 401 | client := NewClient("", "test-client", signature.NewNop(testSignature, testCertHash)) 402 | token, err := client.TokenUpdate("test-oid", "test-redirect") 403 | suite.ErrorIs(err, ErrTokenUpdate) 404 | suite.ErrorIs(err, ErrRequest) 405 | suite.Nil(token) 406 | }) 407 | 408 | suite.Run("error sign", func() { 409 | client := NewClient("", "test-client", signature.NewNop("", "")) 410 | token, err := client.TokenUpdate("test-oid", "test-redirect") 411 | suite.ErrorIs(err, ErrTokenUpdate) 412 | suite.ErrorIs(err, ErrSign) 413 | suite.Nil(token) 414 | }) 415 | 416 | suite.Run("error signer is nil", func() { 417 | client := NewClient("", "test-client", nil) 418 | token, err := client.TokenUpdate("test-oid", "test-redirect") 419 | suite.ErrorIs(err, ErrTokenUpdate) 420 | suite.ErrorIs(err, ErrSign) 421 | suite.Nil(token) 422 | }) 423 | } 424 | -------------------------------------------------------------------------------- /esia/aas/doc.go: -------------------------------------------------------------------------------- 1 | // OAuth2-клиент для запроса согласия и маркера доступа ЕСИА 2 | // для получателей услуг ЕПГУ — физических лиц. 3 | // 4 | // # Методы 5 | // 6 | // - [Client.AuthURI] — формирует ссылку на страницу ЕСИА для предоставления пользователем запрошенных прав 7 | // - [Client.ParseCallback] — возвращает код авторизации из callback-запроса к redirect_uri 8 | // - [Client.TokenExchange] — обменивает код авторизации на маркер доступа (токен) 9 | // - [Client.TokenUpdate] — обновляет маркер доступа по идентификатору пользователя (OID) 10 | // 11 | // # Примеры 12 | // 13 | // - [github.com/ofstudio/go-api-epgu/examples/esia-token-request] — запрос согласия пользователя и получения маркера доступа 14 | // - [github.com/ofstudio/go-api-epgu/examples/esia-token-update] — обновление маркера доступа 15 | // 16 | // # Требования 17 | // 1. Информационная система (ИС) должна быть зарегистрирована на 18 | // Технологическом портале ЕСИА: продуктовом или тестовом (SVCDEV) 19 | // 2. Для ИС должен быть выпущен необходимый сертификат 20 | // 3. Публичная часть сертификата должна быть загружена на Технологический портал ЕСИА 21 | // 4. Выполнены все необходимые шаги регламента подключения ИС к тестовой 22 | // или продуктовой среде ЕСИА и согласована заявка на доступ ИС к необходимым скоупам 23 | // 24 | // # Руководящие документы 25 | // 1. Методические рекомендации по интеграции с REST API Цифрового профиля: https://digital.gov.ru/ru/documents/7166/ 26 | // 2. Методические рекомендации по использованию ЕСИА: https://digital.gov.ru/ru/documents/6186/ 27 | // 3. Руководство пользователя ЕСИА: https://digital.gov.ru/ru/documents/6182/ 28 | // 4. Руководство пользователя технологического портала ЕСИА: https://digital.gov.ru/ru/documents/6190/ 29 | // 30 | // # Адреса Технологического портала ЕСИА 31 | // - Тестовая среда (SVCDEV): https://esia-portal1.test.gosuslugi.ru/console/tech 32 | // - Продуктовая среда: https://esia.gosuslugi.ru/console/tech/ 33 | // 34 | // # Адреса Портала Госуслуг 35 | // - Тестовая среда (SVCDEV): https://svcdev-beta.test.gosuslugi.ru 36 | // - Продуктовая среда: https://lk.gosuslugi.ru 37 | // 38 | // # Страница предоставленных согласий пользователя на Портале Госуслуг 39 | // - Тестовая среда (SVCDEV): https://svcdev-betalk.test.gosuslugi.ru/settings/third-party/agreements/acting 40 | // - Продуктовая среда: https://lk.gosuslugi.ru/settings/third-party/agreements/acting 41 | package aas 42 | -------------------------------------------------------------------------------- /esia/aas/errors.go: -------------------------------------------------------------------------------- 1 | package aas 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | ) 12 | 13 | // Ошибки первого уровня. 14 | var ( 15 | ErrAuthURI = errors.New("ошибка при создании авторизационной ссылки") 16 | ErrParseCallback = errors.New("ошибка обратного вызова") 17 | ErrTokenExchange = errors.New("ошибка запроса токена") 18 | ErrTokenUpdate = errors.New("ошибка обновления токена") 19 | ) 20 | 21 | // Ошибки второго уровня. 22 | var ( 23 | ErrNoState = errors.New("отсутствует поле state") 24 | ErrGUID = errors.New("не удалось сгенерировать GUID") 25 | ErrSign = errors.New("ошибка подписания") 26 | ErrRequest = errors.New("ошибка HTTP-запроса") 27 | ErrJSONUnmarshal = errors.New("ошибка чтения JSON") 28 | ErrUnexpectedContentType = errors.New("неожиданный тип содержимого") 29 | ) 30 | 31 | // Ошибки ЕСИА. 32 | var ( 33 | ErrESIA_036700 = errors.New("ESIA-036700: Не указана мнемоника типа согласия") 34 | ErrESIA_036701 = errors.New("ESIA-036701: Не найден тип согласия") 35 | ErrESIA_036702 = errors.New("ESIA-036702: Не указан обязательный скоуп для типа согласия") 36 | ErrESIA_036703 = errors.New("ESIA-036703: Указанные скоупы выходят за рамки разрешенных для типа согласия") 37 | ErrESIA_036704 = errors.New("ESIA-036704: Запрещено указывать скоупы для типа согласия") 38 | ErrESIA_036705 = errors.New("ESIA-036705: Необходимо указать хотя бы одно действие") 39 | ErrESIA_036706 = errors.New("ESIA-036706: Указанное действие не существует") 40 | ErrESIA_036707 = errors.New("ESIA-036707: Необходимо указать хотя бы одну цель") 41 | ErrESIA_036716 = errors.New("ESIA-036716: Указано некорректное время истечения срока действия согласия") 42 | ErrESIA_036726 = errors.New("ESIA-036726: Указанная цель не существует") 43 | ErrESIA_036727 = errors.New("ESIA-036727: Необходимо указать одну цель согласия") 44 | ErrESIA_007002 = errors.New("ESIA-007002: Несоответствие сертификата и мнемоники информационной системы или отсутствие сертификата для данной системы в ЕСИА") 45 | ErrESIA_007003 = errors.New("ESIA-007003: В запросе отсутствует обязательный параметр, запрос включает в себя неверное значение параметра \nили включает параметр несколько раз\n") 46 | ErrESIA_007004 = errors.New("ESIA-007004: Владелец ресурса или сервис авторизации отклонил запрос") 47 | ErrESIA_007005 = errors.New("ESIA-007005: Система-клиент не имеет права запрашивать получение маркера доступа таким методом") 48 | ErrESIA_007006 = errors.New("ESIA-007006: Запрошенная область доступа (scope) указана неверно, неизвестно или сформирована некорректно") 49 | ErrESIA_007007 = errors.New("ESIA-007007: Возникла неожиданная ошибка в работе сервиса авторизации, которая привела к невозможности выполнить запрос") 50 | ErrESIA_007008 = errors.New("ESIA-007008: Сервис авторизации в настоящее время не может выполнить запрос из-за большой нагрузки или технических работ на сервере") 51 | ErrESIA_007009 = errors.New("ESIA-007009: Сервис авторизации не поддерживает получение маркера доступа этим методом") 52 | ErrESIA_007011 = errors.New("ESIA-007011: Авторизационный код или маркер обновления недействителен, просрочен, отозван или не соответствует адресу ресурса, указанному в запросе на авторизацию, или был выдан другой системе-клиенту") 53 | ErrESIA_007012 = errors.New("ESIA-007012: Тип авторизационного кода не поддерживается сервисом авторизации") 54 | ErrESIA_007013 = errors.New("ESIA-007013: Запрос не содержит указания на область доступа (scope)") 55 | ErrESIA_007014 = errors.New("ESIA-007014: Запрос не содержит обязательного параметра") 56 | ErrESIA_007015 = errors.New("ESIA-007015: Неверное время запроса") 57 | ErrESIA_007019 = errors.New("ESIA-007019: Отсутствует разрешение на доступ") 58 | ErrESIA_007023 = errors.New("ESIA-007023: Указанный в запросе отсутствует среди разрешенных для ИС") 59 | ErrESIA_007038 = errors.New("ESIA-007038: Ошибка получения параметров из запроса") 60 | ErrESIA_007039 = errors.New("ESIA-007039: В изначальном запросе на /v2/ac, параметр не был указан") 61 | ErrESIA_007040 = errors.New("ESIA-007040: Ошибка сравнения исходного и контрольного значений") 62 | ErrESIA_007046 = errors.New("ESIA-007046: Запрос otp невозможен, а в области доступа (scope) указано обязательное прохождение пользователем двухфакторной авторизации, недоступный пользователю") 63 | ErrESIA_007053 = errors.New("ESIA-007053: client_secret сформирован некорректно. client_secret не соответствует строке-сертификату, информационной системе или используемый сертификат не активен") 64 | ErrESIA_007055 = errors.New("ESIA-007055: Вход в систему осуществляется с неподтвержденной учетной записью") 65 | ErrESIA_007060 = errors.New("ESIA-007060: Значение параметра в запросе указано неверно") 66 | ErrESIA_007061 = errors.New("ESIA-007061: Значение параметра в запросе указано неверно") 67 | ErrESIA_007062 = errors.New("ESIA-007062: Тип или роль пользователя в запросе указана неверно") 68 | ErrESIA_007194 = errors.New("ESIA-007194: Запрос области доступа для организации, сотрудником которой пользователь не является ") 69 | ErrESIA_008010 = errors.New("ESIA-008010: Не удалось произвести аутентификацию системы-клиента") 70 | 71 | // Неизвестная ошибка ЕСИА 72 | ErrESIA_unknown = errors.New("неизвестная ошибка ЕСИА") 73 | ) 74 | 75 | const errESIAPrefixLen = len("ESIA-036700") 76 | 77 | // esiaError - возвращает ошибку ЕСИА по коду ошибки в описании ошибки. 78 | func esiaError(description string) error { 79 | var prefix string 80 | if len(description) >= errESIAPrefixLen { 81 | prefix = description[:errESIAPrefixLen] 82 | } else { 83 | prefix = description 84 | } 85 | err, ok := errESIAIdx[prefix] 86 | if !ok { 87 | err = ErrESIA_unknown 88 | } 89 | return err 90 | } 91 | 92 | // callbackError - возвращает ошибку ЕСИА по коду ошибки в query-параметрах callback-запроса 93 | // к redirect_uri от ЕСИА. 94 | // Пример сообщения об ошибке: 95 | // 96 | // ESIA-007014: Запрос не содержит обязательного параметра [error='invalid_request', error_description='ESIA-007014: The request does not contain the mandatory parameter' state='48d1a8dc-0b7d-418a-b4ef-2c7797f77dc9']' 97 | func callbackError(query url.Values) error { 98 | return fmt.Errorf( 99 | "%w [error='%s', error_description='%s', state='%s']", 100 | esiaError(query.Get("error_description")), 101 | query.Get("error"), 102 | query.Get("error_description"), 103 | query.Get("state"), 104 | ) 105 | } 106 | 107 | // responseError - возвращает ошибку ЕСИА по коду ошибки в ответе от ЕСИА при обмене кода на маркер доступа. 108 | // Пример сообщения об ошибке: 109 | // 110 | // HTTP 400 Bad request: ESIA-007014: Запрос не содержит обязательного параметра [error='invalid_request', error_description='ESIA-007014: The request does not contain the mandatory parameter' state='48d1a8dc-0b7d-418a-b4ef-2c7797f77dc9']' 111 | func responseError(res *http.Response) error { 112 | if res == nil || res.StatusCode < 400 { 113 | return nil 114 | } 115 | return fmt.Errorf("HTTP %s: %w", res.Status, bodyError(res)) 116 | } 117 | 118 | func bodyError(res *http.Response) error { 119 | //goland:noinspection ALL 120 | defer res.Body.Close() 121 | body, err := io.ReadAll(res.Body) 122 | if err != nil { 123 | return fmt.Errorf("%w: %w", ErrRequest, err) 124 | } 125 | ct := res.Header.Get("Content-Type") 126 | if strings.HasPrefix(ct, "application/json") { 127 | return jsonError(body) 128 | } 129 | return fmt.Errorf("%w: '%s'", ErrUnexpectedContentType, ct) 130 | } 131 | 132 | func jsonError(body []byte) error { 133 | errResponse := &ErrorResponse{} 134 | err := json.Unmarshal(body, errResponse) 135 | if err != nil { 136 | return fmt.Errorf("%w: %w", ErrJSONUnmarshal, err) 137 | } 138 | return fmt.Errorf( 139 | "%w [error='%s', error_description='%s', state='%s']", 140 | esiaError(errResponse.ErrorDescription), 141 | errResponse.Error, 142 | errResponse.ErrorDescription, 143 | errResponse.State, 144 | ) 145 | } 146 | 147 | var errESIAIdx = map[string]error{ 148 | "ESIA-036700": ErrESIA_036700, 149 | "ESIA-036701": ErrESIA_036701, 150 | "ESIA-036702": ErrESIA_036702, 151 | "ESIA-036703": ErrESIA_036703, 152 | "ESIA-036704": ErrESIA_036704, 153 | "ESIA-036705": ErrESIA_036705, 154 | "ESIA-036706": ErrESIA_036706, 155 | "ESIA-036707": ErrESIA_036707, 156 | "ESIA-036716": ErrESIA_036716, 157 | "ESIA-036726": ErrESIA_036726, 158 | "ESIA-036727": ErrESIA_036727, 159 | "ESIA-007002": ErrESIA_007002, 160 | "ESIA-007003": ErrESIA_007003, 161 | "ESIA-007004": ErrESIA_007004, 162 | "ESIA-007005": ErrESIA_007005, 163 | "ESIA-007006": ErrESIA_007006, 164 | "ESIA-007007": ErrESIA_007007, 165 | "ESIA-007008": ErrESIA_007008, 166 | "ESIA-007009": ErrESIA_007009, 167 | "ESIA-007011": ErrESIA_007011, 168 | "ESIA-007012": ErrESIA_007012, 169 | "ESIA-007013": ErrESIA_007013, 170 | "ESIA-007014": ErrESIA_007014, 171 | "ESIA-007015": ErrESIA_007015, 172 | "ESIA-007019": ErrESIA_007019, 173 | "ESIA-007023": ErrESIA_007023, 174 | "ESIA-007038": ErrESIA_007038, 175 | "ESIA-007039": ErrESIA_007039, 176 | "ESIA-007040": ErrESIA_007040, 177 | "ESIA-007046": ErrESIA_007046, 178 | "ESIA-007053": ErrESIA_007053, 179 | "ESIA-007055": ErrESIA_007055, 180 | "ESIA-007060": ErrESIA_007060, 181 | "ESIA-007061": ErrESIA_007061, 182 | "ESIA-007062": ErrESIA_007062, 183 | "ESIA-007194": ErrESIA_007194, 184 | "ESIA-008010": ErrESIA_008010, 185 | } 186 | -------------------------------------------------------------------------------- /esia/aas/errors_test.go: -------------------------------------------------------------------------------- 1 | package aas 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/suite" 7 | ) 8 | 9 | type errorSuite struct { 10 | suite.Suite 11 | } 12 | 13 | func TestError(t *testing.T) { 14 | suite.Run(t, new(errorSuite)) 15 | } 16 | 17 | func (suite *errorSuite) Test_esiaError() { 18 | suite.Run("known ESIA errors", func() { 19 | suite.Equal(ErrESIA_036700, esiaError("ESIA-036700: Не указана мнемоника типа согласия")) 20 | suite.Equal(ErrESIA_036701, esiaError("ESIA-036701: Не найден тип согласия")) 21 | suite.Equal(ErrESIA_036702, esiaError("ESIA-036702: Не указан обязательный скоуп для типа согласия")) 22 | }) 23 | 24 | suite.Run("short and unknown", func() { 25 | suite.Equal(ErrESIA_unknown, esiaError("ESIA-999999")) 26 | suite.Equal(ErrESIA_unknown, esiaError("ESIA")) 27 | suite.Equal(ErrESIA_unknown, esiaError("")) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /esia/aas/permissions.go: -------------------------------------------------------------------------------- 1 | package aas 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | ) 7 | 8 | // Permissions - список запрашиваемых прав доступа. 9 | // 10 | // Подробнее см "Методические рекомендации по интеграции с REST API Цифрового профиля", 11 | // раздел "Структура JSON-объекта параметра «permissions»". 12 | type Permissions []Permission 13 | 14 | // Permission - разрешение на доступ к ресурсу. 15 | // 16 | // Подробнее см "Методические рекомендации по интеграции с REST API Цифрового профиля", 17 | // раздел "Структура JSON-объекта параметра «permissions»". 18 | type Permission struct { 19 | ResponsibleObject string `json:"responsibleObject,omitempty"` // Ответственный объект (название организации) 20 | Sysname string `json:"sysname"` // Мнемоника типа согласия 21 | Expire int `json:"expire,omitempty"` // Срок, на который будет выдано согласие после утверждения (в минутах) 22 | Actions []PermissionAction `json:"actions"` // Перечень мнемоник действий 23 | Purposes []PermissionPurpose `json:"purposes"` // Перечень мнемоник целей согласия 24 | Scopes []PermissionScope `json:"scopes"` // Перечень мнемоник областей доступа 25 | } 26 | 27 | // PermissionPurpose - мнемоника цели согласия объекта [Permission]. 28 | type PermissionPurpose struct { 29 | Sysname string `json:"sysname"` 30 | } 31 | 32 | // PermissionScope - мнемоника области доступа объекта [Permission]. 33 | type PermissionScope struct { 34 | Sysname string `json:"sysname"` 35 | } 36 | 37 | // PermissionAction - мнемоника действия объекта [Permission]. 38 | type PermissionAction struct { 39 | Sysname string `json:"sysname"` 40 | } 41 | 42 | // Base64String - кодирует список запрашиваемых разрешений в строку base64 43 | func (p Permissions) Base64String() string { 44 | j, _ := json.Marshal(p) 45 | return base64.RawURLEncoding.EncodeToString(j) 46 | } 47 | -------------------------------------------------------------------------------- /esia/aas/permissions_test.go: -------------------------------------------------------------------------------- 1 | package aas 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestPermissions_Base64String(t *testing.T) { 12 | p := Permissions{ 13 | Permission{ 14 | ResponsibleObject: "test", 15 | Sysname: "test", 16 | Expire: 1, 17 | Actions: []PermissionAction{{Sysname: "test"}}, 18 | Purposes: []PermissionPurpose{{Sysname: "test"}}, 19 | Scopes: []PermissionScope{{Sysname: "test"}}, 20 | }, 21 | } 22 | 23 | base64String := p.Base64String() 24 | jsonBytes, err := base64.RawURLEncoding.DecodeString(base64String) 25 | require.NoError(t, err) 26 | var pGot Permissions 27 | err = json.Unmarshal(jsonBytes, &pGot) 28 | require.NoError(t, err) 29 | require.Equal(t, p, pGot) 30 | } 31 | -------------------------------------------------------------------------------- /esia/aas/request.go: -------------------------------------------------------------------------------- 1 | package aas 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | func (c *Client) request( 11 | method, 12 | endpoint, 13 | contentType string, 14 | body io.Reader, 15 | result any, 16 | ) error { 17 | req, err := http.NewRequest(method, c.baseURI+endpoint, body) 18 | if err != nil { 19 | return fmt.Errorf("%w: %w", ErrRequest, err) 20 | } 21 | 22 | if contentType != "" { 23 | req.Header.Set("Content-Type", contentType) 24 | } 25 | 26 | c.logReq(req) 27 | 28 | res, err := c.httpClient.Do(req) 29 | if err != nil { 30 | return fmt.Errorf("%w: %w", ErrRequest, err) 31 | } 32 | 33 | c.logRes(res) 34 | 35 | if res.StatusCode >= 400 { 36 | return responseError(res) 37 | } 38 | 39 | //goland:noinspection ALL 40 | defer res.Body.Close() 41 | resBody, err := io.ReadAll(res.Body) 42 | if err != nil { 43 | return fmt.Errorf("%w: %w", ErrRequest, err) 44 | } 45 | if err = json.Unmarshal(resBody, result); err != nil { 46 | return fmt.Errorf("%w: %w", ErrJSONUnmarshal, err) 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /esia/signature/doc.go: -------------------------------------------------------------------------------- 1 | // Провайдеры электронной подписи запросов к ЕСИА. 2 | // 3 | // # Реализации 4 | // 1. [LocalCryptoPro] — электронная подпись с использованием алгоритма ГОСТ Р 34.10-2012 (256 бит) и 5 | // инсталляции КриптоПро CSP 5 для рабочих станций. Может быть использована для отладки взаимодействия 6 | // с ЕСИА. Не подходит в качестве серверного решения. 7 | // 2. [Nop] — тестовый провайдер электронной подписи: возвращает фиксированное значение подписи. 8 | // Используется для юнит-тестов. 9 | package signature 10 | -------------------------------------------------------------------------------- /esia/signature/errors.go: -------------------------------------------------------------------------------- 1 | package signature 2 | 3 | import "errors" 4 | 5 | // Ошибки провайдера [LocalCryptoPro] 6 | var ( 7 | ErrTempFileCreate = errors.New("ошибка при создании временного файла") 8 | ErrTempFileWrite = errors.New("ошибка записи во временный файл") 9 | ErrTempFileRead = errors.New("ошибка чтения временного файла") 10 | ErrCPTestExec = errors.New("ошибка запуска cptest") 11 | ) 12 | -------------------------------------------------------------------------------- /esia/signature/interface.go: -------------------------------------------------------------------------------- 1 | package signature 2 | 3 | // Provider - интерфейс провайдера электронной подписи запросов к ЕСИА. 4 | // Провайдер должен реализовать 5 | // - подписание данных 6 | // - возвращать хэш сертификата 7 | type Provider interface { 8 | Sign(data []byte) ([]byte, error) 9 | CertHash() string 10 | } 11 | -------------------------------------------------------------------------------- /esia/signature/local-cp-gost.go: -------------------------------------------------------------------------------- 1 | package signature 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | // LocalCryptoPro реализация [signature.Provider] с использованием 10 | // утилиты csptest из локально установленного пакета КриптоПро CSP для рабочих станций версии 5 и выше. 11 | // Для подписания используется алгоритм ГОСТ Р 34.10-2012 (256 бит). 12 | // 13 | // ВАЖНО: используйте эту реализацию только для отладки взаимодействия с ЕСИА, 14 | // тк КриптоПро CSP 5 для рабочих станций не может использоваться в качестве серверного решения. 15 | type LocalCryptoPro struct { 16 | cspTestPath string 17 | cspContainer string 18 | certHash string 19 | cmd cmdInterface 20 | } 21 | 22 | // NewLocalCryptoPro - конструктор LocalCryptoPro. 23 | // 24 | // # cspTestPath 25 | // 26 | // Полный путь к утилите csptest из пакета КриптоПро CSP: 27 | // - Mac: "/opt/cprocsp/bin/csptest" 28 | // - Win: "C:\Program Files\Crypto Pro\CSP\сsptest.exe" 29 | // 30 | // # cspContainer 31 | // 32 | // Имя контейнера сертификата. 33 | // Сертификат ИС (6 файлов .key), используемый для подписи запросов к ЕСИА, 34 | // должен быть записан на съемный носитель (флешку). 35 | // Для получения имени контейнера, подключите съемный носитель с сертификатом 36 | // и запустите утилиту csptest (csptest.exe для Windows) из пакета КриптоПро CSP: 37 | // 38 | // csptest -keyset 39 | // 40 | // Команда выведет имя контейнера: 41 | // 42 | // ... 43 | // Container name: "X9X1XYZA9EZZWZ42" 44 | // ... 45 | // 46 | // # certHash 47 | // 48 | // Хеш сертификата. 49 | // Подключите съемный носитель с сертификатом 50 | // и запустите утилиту cpverify (cpverify.exe для Windows) из пакета КриптоПро CSP: 51 | // 52 | // cpverify -mk -alg GR3411_2012_256 53 | // 54 | // Команда выведет хеш сертификата: 55 | // 56 | // 1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF0 57 | func NewLocalCryptoPro(cspTestPath, cspContainer, certHash string) *LocalCryptoPro { 58 | return &LocalCryptoPro{ 59 | cspTestPath: cspTestPath, 60 | cspContainer: cspContainer, 61 | certHash: certHash, 62 | cmd: osExec{}, 63 | } 64 | } 65 | 66 | // CertHash возвращает хэш сертификата 67 | func (p *LocalCryptoPro) CertHash() string { 68 | return p.certHash 69 | } 70 | 71 | // Sign - возвращает подпись для данных c использованием алгоритма ГОСТ Р 34.10-2012 (256 бит). 72 | func (p *LocalCryptoPro) Sign(data []byte) ([]byte, error) { 73 | // создаем временный файл для подписываемых данных 74 | dataTempFile, err := os.CreateTemp("", "data") 75 | if err != nil { 76 | return nil, fmt.Errorf("%w: %w", ErrTempFileCreate, err) 77 | } 78 | //goland:noinspection ALL 79 | defer os.Remove(dataTempFile.Name()) 80 | 81 | // создаем временный файл для подписи 82 | signTempFile, err := os.CreateTemp("", "signature") 83 | if err != nil { 84 | return nil, fmt.Errorf("%w: %w", ErrTempFileCreate, err) 85 | } 86 | //goland:noinspection ALL 87 | defer os.Remove(signTempFile.Name()) 88 | 89 | // записываем подписываемые данные во временный файл 90 | err = os.WriteFile(dataTempFile.Name(), data, 0644) 91 | if err != nil { 92 | return nil, fmt.Errorf("%w: %w", ErrTempFileWrite, err) 93 | } 94 | 95 | // вызываем утилиту csptest, которая подписывает данные 96 | // и записывает подпись во временный файл 97 | 98 | err = p.cmd.Run(p.cspTestPath, 99 | "-keys", 100 | "-sign", "GOST12_256", 101 | "-cont", p.cspContainer, 102 | "-keytype", "exchange", 103 | "-in", dataTempFile.Name(), 104 | "-out", signTempFile.Name(), 105 | ) 106 | if err != nil { 107 | return nil, fmt.Errorf("%w: %w", ErrCPTestExec, err) 108 | } 109 | 110 | // читаем подпись из временного файла 111 | signBytes, err := os.ReadFile(signTempFile.Name()) 112 | if err != nil { 113 | return nil, fmt.Errorf("%w: %w", ErrTempFileRead, err) 114 | } 115 | 116 | // реверсируем байты подписи, тк это в данном случае требуется 117 | // http://www.gogost.cypherpunks.ru/FAQ.html 118 | for i, j := 0, len(signBytes)-1; i < j; i, j = i+1, j-1 { 119 | signBytes[i], signBytes[j] = signBytes[j], signBytes[i] 120 | } 121 | 122 | return signBytes, nil 123 | } 124 | 125 | type cmdInterface interface { 126 | Run(path string, args ...string) error 127 | } 128 | 129 | type osExec struct{} 130 | 131 | func (o osExec) Run(path string, args ...string) error { 132 | return exec.Command(path, args...).Run() 133 | } 134 | -------------------------------------------------------------------------------- /esia/signature/local-cp-gost_test.go: -------------------------------------------------------------------------------- 1 | package signature 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | "github.com/stretchr/testify/suite" 10 | ) 11 | 12 | type suiteLocalCryptoPro struct { 13 | suite.Suite 14 | signer *LocalCryptoPro 15 | } 16 | 17 | func TestLocalCryptoPro(t *testing.T) { 18 | suite.Run(t, new(suiteLocalCryptoPro)) 19 | } 20 | 21 | func (suite *suiteLocalCryptoPro) SetupTest() { 22 | suite.signer = NewLocalCryptoPro("test", "test", "test_hash") 23 | suite.signer.cmd = newTestCmd(suite.T()) 24 | } 25 | 26 | func (suite *suiteLocalCryptoPro) TestSign() { 27 | suite.Run("success", func() { 28 | signature, err := suite.signer.Sign([]byte(testDataToSign)) 29 | suite.NoError(err) 30 | suite.Equal(testSignatureReversed, string(signature)) 31 | }) 32 | 33 | suite.Run("error", func() { 34 | signature, err := suite.signer.Sign([]byte{}) 35 | suite.ErrorIs(err, ErrCPTestExec) 36 | suite.Nil(signature) 37 | }) 38 | } 39 | 40 | func (suite *suiteLocalCryptoPro) TestCertHash() { 41 | suite.Equal("test_hash", suite.signer.CertHash()) 42 | } 43 | 44 | const ( 45 | testDataToSign = "this is a test data" 46 | testSignature = "this is a test signature" 47 | testSignatureReversed = "erutangis tset a si siht" 48 | ) 49 | 50 | type testCmd struct { 51 | t *testing.T 52 | } 53 | 54 | func newTestCmd(t *testing.T) *testCmd { 55 | return &testCmd{t: t} 56 | } 57 | 58 | func (c *testCmd) Run(name string, args ...string) error { 59 | 60 | require.Equal(c.t, 11, len(args)) 61 | require.Equal(c.t, "-keys", args[0]) 62 | require.Equal(c.t, "-sign", args[1]) 63 | require.Equal(c.t, "GOST12_256", args[2]) 64 | require.Equal(c.t, "-cont", args[3]) 65 | require.NotEmpty(c.t, args[4]) 66 | require.Equal(c.t, "-keytype", args[5]) 67 | require.Equal(c.t, "exchange", args[6]) 68 | require.Equal(c.t, "-in", args[7]) 69 | require.NotEmpty(c.t, args[8]) 70 | require.Equal(c.t, "-out", args[9]) 71 | require.NotEmpty(c.t, args[10]) 72 | 73 | inFname := args[8] 74 | outFname := args[10] 75 | 76 | // читаем подписываемые данные из временного файла 77 | inBytes, err := os.ReadFile(inFname) 78 | require.NoError(c.t, err) 79 | 80 | // если входные данные пустые, то возвращаем ошибку 81 | if len(inBytes) == 0 { 82 | return errors.New("some error") 83 | } 84 | 85 | require.Equal(c.t, testDataToSign, string(inBytes)) 86 | 87 | // записываем подпись во временный файл 88 | err = os.WriteFile(outFname, []byte(testSignature), 0644) 89 | require.NoError(c.t, err) 90 | 91 | if len(inBytes) == 0 { 92 | return errors.New("test error") 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /esia/signature/nop.go: -------------------------------------------------------------------------------- 1 | package signature 2 | 3 | import "errors" 4 | 5 | // Nop - тестовый провайдер подписи запросов. 6 | // Возвращает фиксированные значения подписи и хэша сертификата. 7 | type Nop struct { 8 | signature string 9 | certHash string 10 | } 11 | 12 | // NewNop - конструктор [Nop]. 13 | // Если в signature передана пустая строка, то при вызове [Nop.Sign] будет возвращена ошибка. 14 | func NewNop(signature, certHash string) *Nop { 15 | return &Nop{signature: signature, certHash: certHash} 16 | } 17 | 18 | // Sign - возвращает фиксированную подпись. 19 | // Если в конструкторе [NewNop] в качестве signature была передана пустая строка, 20 | // то возвращается ошибка. 21 | func (p *Nop) Sign(_ []byte) ([]byte, error) { 22 | if p.signature == "" { 23 | return nil, errors.New("test") 24 | } 25 | return []byte(p.signature), nil 26 | } 27 | 28 | // CertHash - возвращает фиксированный хэш сертификата. 29 | func (p *Nop) CertHash() string { 30 | return p.certHash 31 | } 32 | -------------------------------------------------------------------------------- /examples/esia-token-request/main.go: -------------------------------------------------------------------------------- 1 | // Пример запроса согласия у пользователя и получения маркера доступа ЕСИА 2 | // для работы с API Госуслуг (АПИ ЕПГУ). 3 | // 4 | // # Основные шаги процесса 5 | // 1. Создание ссылки на страницу предоставления прав доступа ЕСИА (/oauth2/v2/ac) 6 | // 2. Переход пользователя по ссылке 7 | // 3. Получение авторизационного кода из параметров обратного вызова на redirect_uri 8 | // 4. Обмен авторизационного кода на маркер доступа (/oauth2/v3/te) 9 | // 10 | // # Требования 11 | // 1. Информационная система должна быть зарегистрирована на 12 | // Технологическом портале ЕСИА: продуктовом или тестовом (SVCDEV) 13 | // 2. Для ИС должен быть выпущен необходимый сертификат 14 | // 3. Публичная часть сертификата должна быть загружена на Технологический портал ЕСИА 15 | // 4. Выполнены все необходимые шаги регламента подключения ИС к тестовой 16 | // или продуктовой среде ЕСИА и согласована заявка на доступ ИС к необходимым скоупам 17 | // 5. Доступ к учетной записи пользователя на тестовом (SVCDEV) или продуктовом портале Госуслуг 18 | // 6. Локально установленный пакет КриптоПро CSP (https://www.cryptopro.ru/products/csp) 19 | // 7. Сертификат ИС (6 файлов .key) записанный на съемном носителе (флешке) 20 | // для работы с КриптоПро CSP 21 | // 22 | // # Адреса Технологического портала ЕСИА 23 | // - Тестовая среда (SVCDEV): https://esia-portal1.test.gosuslugi.ru/console/tech 24 | // - Продуктовая среда: https://esia.gosuslugi.ru/console/tech/ 25 | // 26 | // # Адреса Портала Госуслуг 27 | // - Тестовая среда (SVCDEV): https://svcdev-beta.test.gosuslugi.ru 28 | // - Продуктовая среда: https://lk.gosuslugi.ru 29 | // 30 | // # Страница предоставленных согласий пользователя на Портале Госуслуг 31 | // - Тестовая среда (SVCDEV): https://svcdev-betalk.test.gosuslugi.ru/settings/third-party/agreements/acting 32 | // - Продуктовая среда: https://lk.gosuslugi.ru/settings/third-party/agreements/acting 33 | package main 34 | 35 | import ( 36 | "fmt" 37 | "log" 38 | "net/http" 39 | 40 | "github.com/ofstudio/go-api-epgu/esia/aas" 41 | "github.com/ofstudio/go-api-epgu/esia/signature" 42 | "github.com/ofstudio/go-api-epgu/utils" 43 | ) 44 | 45 | // Параметры подключения к ЕСИА. 46 | // Адреса ЕСИА: 47 | // - Тестовая среда (SVCDEV): https://esia-portal1.test.gosuslugi.ru 48 | // - Продуктовая среда: https://esia.gosuslugi.ru 49 | // 50 | // ВАЖНО: значения полей вида "<< поле >>" необходимо заполнить актуальными данными вашей ИС. 51 | const ( 52 | mnemonic = "<< мнемоника ИС потребителя >>" // Мнемоника ИС на портале ЕСИА 53 | esiaURI = "<< адрес ЕСИА >>" // Адрес портала ЕСИА 54 | redirectURI = "http://localhost:8000/callback" // Адрес redirect_uri на стороне потребителя 55 | ) 56 | 57 | // Параметры КриптоПро для signature.LocalCryptoPro. 58 | // 59 | // Ссылка на страницу предоставления прав доступа ЕСИА должна содержать параметры 60 | // - client_secret с электронной подписью ИС потребителя 61 | // - client_certificate_hash с хешем сертификата 62 | // 63 | // Подробнее см "Методические рекомендации по использованию ЕСИА", 64 | // раздел "Получение авторизационного кода (v2/ac)". 65 | // 66 | // В данном примере для формирования подписи используется реализация signature.LocalCryptoPro, 67 | // которая вызывает утилиту csptest из локально установленного пакета КриптоПро CSP. 68 | // 69 | // ВАЖНО: значения полей вида "<< поле >>" необходимо заполнить актуальными 70 | // данными вашего сертификата. 71 | const ( 72 | // cspTestPath - полный путь к утилите csptest из пакета КриптоПро CSP: 73 | // - Mac: "/opt/cprocsp/bin/csptest" 74 | // - Win: "C:\Program Files\Crypto Pro\CSP\сsptest.exe" 75 | cspTestPath = "<< полный путь к утилите csptest >>" 76 | 77 | // cspContainer - имя контейнера сертификата. 78 | // Сертификат ИС (6 файлов .key), используемый для подписи запросов к ЕСИА, 79 | // должен быть записан на съемный носитель (флешку). 80 | // 81 | // Для получения имени контейнера, подключите съемный носитель с сертификатом 82 | // и запустите утилиту csptest (csptest.exe для Windows) из пакета КриптоПро CSP: 83 | // csptest -keyset 84 | // Команда выведет имя контейнера: 85 | // ... 86 | // Container name: "X9X1XYZA9EZZWZ42" 87 | // ... 88 | cspContainer = "<< имя контейнера >>" 89 | 90 | // certHash - хеш сертификата. 91 | // 92 | // Подключите съемный носитель с сертификатом 93 | // и запустите утилиту cpverify (cpverify.exe для Windows) из пакета КриптоПро CSP: 94 | // cpverify -mk -alg GR3411_2012_256 95 | // Команда выведет хеш сертификата: 96 | // 1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF0 97 | certHash = "<< хеш сертификата >>" 98 | ) 99 | 100 | // permissions - параметр ссылки на страницу предоставления прав доступа, 101 | // описывающий тип согласия, цели согласия, список запрашиваемых разрешений (scopes) 102 | // и действия с данными. 103 | // 104 | // Подробнее см "Методические рекомендации по интеграции с REST API Цифрового профиля", 105 | // раздел "Структура JSON-объекта параметра permissions". 106 | // 107 | // ВАЖНО: значения полей вида "<< поле >>" необходимо заполнить актуальными данными вашей ИС. 108 | var permissions = aas.Permissions{ 109 | { 110 | ResponsibleObject: "<< название организации в ЕСИА >>", // Ответственный объект (название организации) 111 | Sysname: "APIPGU", // Тип согласия 112 | Expire: 525600, // 1 год: макс. срок согласия данного типа 113 | Actions: []aas.PermissionAction{{Sysname: "ALL_ACTIONS_TO_DATA"}}, // Действия с данными 114 | Purposes: []aas.PermissionPurpose{{Sysname: "APIPGU"}}, // Цели согласий 115 | Scopes: []aas.PermissionScope{ 116 | // Скоуп, необходимый для работы с API Госуслуг 117 | {Sysname: "http://lk.gosuslugi.ru/api-order"}, 118 | // Дополнительно, для данного типа согласия можно запросить следующие скоупы 119 | //{Sysname: "snils"}, 120 | //{Sysname: "id_doc"}, 121 | //{Sysname: "gender"}, 122 | //{Sysname: "fullname"}, 123 | //{Sysname: "birthdate"}, 124 | //{Sysname: "addresses"}, 125 | }, 126 | }, 127 | } 128 | 129 | func main() { 130 | // Создаем провайдер электронной подписи запросов 131 | signer := signature.NewLocalCryptoPro(cspTestPath, cspContainer, certHash) 132 | 133 | // Создаем клиент ЕСИА 134 | oauthClient := aas. 135 | NewClient(esiaURI, mnemonic, signer). 136 | WithDebug(log.Default()) // Опция включает полное логирование запросов и ответов к ЕСИА 137 | 138 | // === ШАГ 1 === 139 | // Создание ссылки на страницу предоставления прав доступа (/oauth2/v2/ac) 140 | uri, err := oauthClient.AuthURI("openid", redirectURI, permissions) 141 | if err != nil { 142 | log.Fatal(err) 143 | } 144 | 145 | // === ШАГ 2 === 146 | // Переход пользователя по ссылке (необходимо вручную перейти по ссылке) 147 | log.Print("Ссылка на страницу предоставления прав доступа ЕСИА: ", uri) 148 | 149 | // === ШАГ 3 === 150 | // Получение авторизационного кода из параметров обратного вызова на redirect_uri 151 | http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { 152 | message := "=== Запрос к redirect_uri ===\n\n" + utils.PrettyQuery(r.URL.Query()) 153 | 154 | code, _, err := oauthClient.ParseCallback(r.URL.Query()) 155 | if err != nil { 156 | log.Print(err) 157 | http.Error(w, message+"\nError: "+err.Error(), http.StatusBadRequest) 158 | return 159 | } 160 | 161 | // === ШАГ 4 === 162 | // Обмен авторизационного кода на маркер доступа (/oauth2/v3/te) 163 | message += "\n=== Обмен авторизационного кода на маркер доступа ===\n\n" 164 | res, err := oauthClient.TokenExchange(code, "openid", redirectURI) 165 | if err != nil { 166 | log.Print(err) 167 | http.Error(w, message+err.Error(), http.StatusInternalServerError) 168 | return 169 | } 170 | 171 | message += utils.PrettyJSON(res) 172 | w.WriteHeader(http.StatusOK) 173 | _, _ = fmt.Fprint(w, message) 174 | log.Print("Получен маркер доступа: ", res.AccessToken) 175 | }) 176 | 177 | // Запускаем сервер, который будет слушать запросы к redirectURI 178 | log.Print("Listening " + redirectURI) 179 | log.Fatal(http.ListenAndServe(":8000", nil)) 180 | } 181 | -------------------------------------------------------------------------------- /examples/esia-token-update/main.go: -------------------------------------------------------------------------------- 1 | // Пример обновления маркера доступа ЕСИА для работы с API Госуслуг (АПИ ЕПГУ). 2 | // 3 | // Время жизни маркера доступа ЕСИА для работы с АПИ ЕПГУ составляет 1 сутки. 4 | // После истечения срока действия маркера доступа, возможно его обновление с помощью 5 | // метода /oauth2/v3/te, указав в параметре scope "prm_chg" и OID пользователя на портале Госуслуг. 6 | // 7 | // OID пользователя на портале Госуслуг можно получить из ранее выданного маркера доступа: 8 | // параметр "urn:esia:sbj_id". 9 | // 10 | // Подробнее см "Методические рекомендации по интеграции с REST API Цифрового профиля" 11 | // раздел "Online-режим запроса согласий". 12 | // 13 | // # Требования 14 | // 1. Информационная система должна быть зарегистрирована на 15 | // Технологическом портале ЕСИА: продуктовом или тестовом (SVCDEV) 16 | // 2. Для ИС должен быть выпущен необходимый сертификат 17 | // 3. Публичная часть сертификата должна быть загружена на Технологический портал ЕСИА 18 | // 4. Выполнены все необходимые шаги регламента подключения ИС к тестовой 19 | // или продуктовой среде ЕСИА и согласована заявка на доступ ИС к необходимым скоупам 20 | // 5. Доступ к учетной записи пользователя на тестовом (SVCDEV) или продуктовом портале Госуслуг 21 | // 6. Ранее выданное согласие пользователя на Портале Госуслуг 22 | // 7. Локально установленный пакет КриптоПро CSP (https://www.cryptopro.ru/products/csp) 23 | // 8. Сертификат ИС (6 файлов .key) записанный на съемном носителе (флешке) 24 | // для работы с КриптоПро CSP 25 | // 26 | // # Адреса Технологического портала ЕСИА 27 | // - Тестовая среда (SVCDEV): https://esia-portal1.test.gosuslugi.ru/console/tech 28 | // - Продуктовая среда: https://esia.gosuslugi.ru/console/tech/ 29 | // 30 | // # Адреса Портала Госуслуг 31 | // - Тестовая среда (SVCDEV): https://svcdev-beta.test.gosuslugi.ru 32 | // - Продуктовая среда: https://lk.gosuslugi.ru 33 | // 34 | // # Страница предоставленных согласий пользователя на Портале Госуслуг 35 | // - Тестовая среда (SVCDEV): https://svcdev-betalk.test.gosuslugi.ru/settings/third-party/agreements/acting 36 | // - Продуктовая среда: https://lk.gosuslugi.ru/settings/third-party/agreements/acting 37 | package main 38 | 39 | import ( 40 | "log" 41 | 42 | "github.com/ofstudio/go-api-epgu/esia/aas" 43 | "github.com/ofstudio/go-api-epgu/esia/signature" 44 | ) 45 | 46 | // Параметры подключения к ЕСИА. 47 | // Адреса ЕСИА: 48 | // - Тестовая среда (SVCDEV): https://esia-portal1.test.gosuslugi.ru 49 | // - Продуктовая среда: https://esia.gosuslugi.ru 50 | // 51 | // ВАЖНО: значения полей вида "<< поле >>" необходимо заполнить актуальными данными вашей ИС. 52 | const ( 53 | mnemonic = "<< мнемоника ИС потребителя >>" // Мнемоника ИС на портале ЕСИА 54 | esiaURI = "<< адрес ЕСИА >>" // Адрес портала ЕСИА 55 | redirectURI = "http://localhost:8000/callback" // Адрес redirect_uri на стороне потребителя - должен совпадать с адресом в настройках ИС на портале ЕСИА 56 | userOid = "<< OID пользователя >>" // OID пользователя на портале Госуслуг 57 | ) 58 | 59 | // Параметры КриптоПро для signature.LocalCryptoPro. 60 | // 61 | // Ссылка на страницу предоставления прав доступа ЕСИА должна содержать параметры 62 | // - client_secret с электронной подписью ИС потребителя 63 | // - client_certificate_hash с хешем сертификата 64 | // 65 | // Подробнее см "Методические рекомендации по использованию ЕСИА", 66 | // раздел "Получение авторизационного кода (v2/ac)". 67 | // 68 | // В данном примере для формирования подписи используется реализация signature.LocalCryptoPro, 69 | // которая вызывает утилиту csptest из локально установленного пакета КриптоПро CSP. 70 | // 71 | // ВАЖНО: значения полей вида "<< поле >>" необходимо заполнить актуальными 72 | // данными вашего сертификата. 73 | const ( 74 | // cspTestPath - полный путь к утилите csptest из пакета КриптоПро CSP: 75 | // - Mac: "/opt/cprocsp/bin/csptest" 76 | // - Win: "C:\Program Files\Crypto Pro\CSP\сsptest.exe" 77 | cspTestPath = "<< полный путь к утилите csptest >>" 78 | 79 | // cspContainer - имя контейнера сертификата. 80 | // Сертификат ИС (6 файлов .key), используемый для подписи запросов к ЕСИА, 81 | // должен быть записан на съемный носитель (флешку). 82 | // 83 | // Для получения имени контейнера, подключите съемный носитель с сертификатом 84 | // и запустите утилиту csptest (csptest.exe для Windows) из пакета КриптоПро CSP: 85 | // csptest -keyset 86 | // Команда выведет имя контейнера: 87 | // ... 88 | // Container name: "X9X1XYZA9EZZWZ42" 89 | // ... 90 | cspContainer = "<< имя контейнера >>" 91 | 92 | // certHash - хеш сертификата. 93 | // 94 | // Подключите съемный носитель с сертификатом 95 | // и запустите утилиту cpverify (cpverify.exe для Windows) из пакета КриптоПро CSP: 96 | // cpverify -mk -alg GR3411_2012_256 97 | // Команда выведет хеш сертификата: 98 | // 1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF0 99 | certHash = "<< хеш сертификата >>" 100 | ) 101 | 102 | func main() { 103 | // Создаем провайдер электронной подписи запросов 104 | signer := signature.NewLocalCryptoPro(cspTestPath, cspContainer, certHash) 105 | 106 | // Создаем клиент ЕСИА 107 | oauthClient := aas. 108 | NewClient(esiaURI, mnemonic, signer). 109 | WithDebug(log.Default()) // Опция включает полное логирование запросов и ответов к ЕСИА 110 | 111 | // Обновляем маркер доступа 112 | tokenRes, err := oauthClient.TokenUpdate(userOid, redirectURI) 113 | if err != nil { 114 | log.Fatal(err) 115 | } 116 | 117 | log.Print("Обновленный маркер доступа: ", tokenRes.AccessToken) 118 | } 119 | -------------------------------------------------------------------------------- /examples/order-info/main.go: -------------------------------------------------------------------------------- 1 | // Пример получения детальной информации по отправленному заявлению. 2 | // 3 | // # Требования 4 | // 1. Выполнены все необходимые шаги регламента подключения ИС к тестовой 5 | // или продуктовой среде АПИ ЕПГУ и согласована заявка на доступ ИС 6 | // к необходимой услуге: https://partners.gosuslugi.ru/catalog/api_for_gu 7 | // 2. Получен маркер доступа (токен) ЕСИА: см. пример [github.com/ofstudio/go-api-epgu/examples/esia-token-request]. 8 | // 3. Доступ к учетной записи пользователя на тестовом (SVCDEV) или продуктовом портале Госуслуг 9 | // 4. Номер отправленного заявления на ЕПГУ: см. пример [github.com/ofstudio/go-api-epgu/examples/order-push-chunked] 10 | // 11 | // # Адреса Портала Госуслуг 12 | // - Тестовая среда (SVCDEV): https://svcdev-beta.test.gosuslugi.ru 13 | // - Продуктовая среда: https://lk.gosuslugi.ru 14 | package main 15 | 16 | import ( 17 | "log" 18 | 19 | "github.com/ofstudio/go-api-epgu" 20 | "github.com/ofstudio/go-api-epgu/utils" 21 | ) 22 | 23 | const ( 24 | // Маркер доступа к API ЕПГУ 25 | accessToken = "<< access_token >>" 26 | 27 | // URL для отправки запросов к API ЕПГУ 28 | baseURI = "https://svcdev-beta.test.gosuslugi.ru" 29 | 30 | // Номер заявления на ЕПГУ 31 | orderId = 0000000000 32 | ) 33 | 34 | func main() { 35 | // Создаем клиент для работы с API ЕПГУ 36 | apiClient := apipgu. 37 | NewClient(baseURI). 38 | WithDebug(log.Default()) // Включаем отладку 39 | 40 | // Запрашиваем детальную информацию по заявлению 41 | orderInfo, err := apiClient.OrderInfo(accessToken, orderId) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | 46 | log.Print(utils.PrettyJSON(orderInfo)) 47 | } 48 | -------------------------------------------------------------------------------- /examples/order-push-chunked/main.go: -------------------------------------------------------------------------------- 1 | // Пример создания заявления и загрузки архива по частям. 2 | // 3 | // # Основные шаги процесса 4 | // 5 | // 1. Заполнение данных заявления 6 | // 2. Создание заявления: POST /api/gusmev/order 7 | // 3. Формирование архива с заявлением 8 | // 4. Загрузка архива с заявлением: POST /api/gusmev/push/chunked 9 | // 10 | // В качестве примера используется услуга "Доставка пенсии и социальных выплат ПФР" (10000000109). 11 | // 12 | // # Требования 13 | // 1. Выполнены все необходимые шаги регламента подключения ИС к тестовой 14 | // или продуктовой среде АПИ ЕПГУ и согласована заявка на доступ ИС 15 | // к необходимой услуге: https://partners.gosuslugi.ru/catalog/api_for_gu 16 | // 2. Получен маркер доступа (токен) ЕСИА: см. пример [github.com/ofstudio/go-api-epgu/examples/esia-token-request]. 17 | // 3. Доступ к учетной записи пользователя на тестовом (SVCDEV) или продуктовом портале Госуслуг 18 | // 19 | // # Адреса Портала Госуслуг 20 | // - Тестовая среда (SVCDEV): https://svcdev-beta.test.gosuslugi.ru 21 | // - Продуктовая среда: https://lk.gosuslugi.ru 22 | package main 23 | 24 | import ( 25 | "log" 26 | 27 | "github.com/ofstudio/go-api-epgu" 28 | "github.com/ofstudio/go-api-epgu/services/sfr" 29 | "github.com/ofstudio/go-api-epgu/services/sfr/zdp-10000000109" 30 | ) 31 | 32 | const ( 33 | // Маркер доступа к API ЕПГУ 34 | accessToken = "<< access_token >>" 35 | 36 | // URL для отправки запросов к API ЕПГУ 37 | baseURI = "https://svcdev-beta.test.gosuslugi.ru" 38 | ) 39 | 40 | func main() { 41 | // Создаем клиент для работы с API ЕПГУ 42 | apiClient := apipgu. 43 | NewClient(baseURI). 44 | WithDebug(log.Default()) // Включаем отладку 45 | 46 | // === ШАГ 1 === 47 | // Создаем услугу и заполняем данные заявления 48 | srv, err := zdp.NewService(okato, oktmo, zdpData) 49 | if err != nil { 50 | log.Fatal(err) 51 | } 52 | // Включаем отладку: выводим в консоль все создаваемые XML-документы и метаданные услуги 53 | //srv.WithDebug(log.Default()) 54 | 55 | // === ШАГ 2 === 56 | // Создаем заявление: POST /api/gusmev/order 57 | orderId, err := apiClient.OrderCreate(accessToken, srv.Meta()) 58 | if err != nil { 59 | log.Fatal(err) 60 | } 61 | log.Print("Успешно создано заявление на ЕПГУ с номером ", orderId) 62 | 63 | // === ШАГ 3 === 64 | // Формируем архив с заявлением 65 | archive, err := srv.Archive(orderId) 66 | if err != nil { 67 | log.Fatal(err) 68 | } 69 | 70 | // === ШАГ 4 === 71 | // Загружаем архив с заявлением: POST /api/gusmev/push/chunked 72 | if err = apiClient.OrderPushChunked(accessToken, orderId, archive); err != nil { 73 | log.Fatal(err) 74 | } 75 | log.Print("Архив с вложениями успешно загружен на ЕПГУ в заявление с номером ", orderId) 76 | 77 | // === ШАГ 5 === (опциональный) 78 | // Сохраняем копию архива локально (для проверки) 79 | //filePath := "_data/" + archive.Name + ".zip" 80 | //if err = os.WriteFile(filePath, archive.Data, 0644); err != nil { 81 | // log.Fatal(err) 82 | //} 83 | //log.Print("Архив с вложениями сохранен в файл ", filePath) 84 | 85 | } 86 | 87 | // 88 | // Данные заявления 89 | // 90 | 91 | // ОКАТО, ОКТМО и адрес заявителя 92 | var ( 93 | okato = "92000000000" 94 | oktmo = "92000000000" 95 | addressData = sfr.NewAddressRus(). 96 | WithZipCode("421001"). 97 | WithRegion("Респ. Татарстан"). 98 | WithCity("г. Казань"). 99 | WithStreet("ул. Адоратского"). 100 | WithHouse("д. 2А"). 101 | WithHousing("корп. 1"). 102 | WithFlat("кв. 1") 103 | ) 104 | 105 | // Заявление на доставку пенсии 106 | var zdpData = zdp.ZDP{ 107 | TOSFR: "Клиентская служба (на правах отдела) в Ново-Савиновском районе г.Казани", 108 | Applicant: zdp.Applicant{ 109 | FIO: sfr.FIO{ 110 | LastName: "ИВАНОВ", 111 | FirstName: "ИВАН", 112 | PatronymicName: "ИВАНОВИЧ", 113 | }, 114 | Sex: "М", 115 | BirthDate: sfr.NewDate(1952, 10, 18), 116 | SNILS: sfr.MustParseSNILS("787-900-175 50"), 117 | BirthPlace: sfr.BirthPlace{ 118 | Type: sfr.BirthPlaceSpecial, 119 | City: "Г. ОРЕЛ", 120 | }, 121 | Citizenship: sfr.Citizenship{Type: sfr.CitizenshipRF}, 122 | AddressFact: addressData, 123 | Phone: "89123456789", 124 | IdentityDoc: sfr.IdentityDoc{ 125 | Type: sfr.IdentityDocPassportRF, 126 | Series: "1234", 127 | Number: "567890", 128 | IssuedAt: sfr.NewDate(2000, 10, 20), 129 | IssuedBy: "ФМС России", 130 | }, 131 | }, 132 | DeliveryInfo: zdp.DeliveryInfo{ 133 | Location: zdp.DeliveryBankOrHome, 134 | Method: zdp.DeliveryBank, 135 | Recipient: zdp.DeliveryMyself, 136 | Organisation: "Филиал Банка «Южный» в г. Казани", 137 | AccountNumber: "40817000000000000001", 138 | Address: *addressData, 139 | }, 140 | Confirmation: 1, 141 | } 142 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ofstudio/go-api-epgu 2 | 3 | go 1.21 4 | 5 | require github.com/stretchr/testify v1.8.4 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 6 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /multipart.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | import ( 4 | "fmt" 5 | "mime/multipart" 6 | "net/textproto" 7 | ) 8 | 9 | type multipartFunc func(w *multipart.Writer) error 10 | 11 | type multipartBuilder struct { 12 | w *multipart.Writer 13 | fns []multipartFunc 14 | } 15 | 16 | func newMultipartBuilder(w *multipart.Writer) *multipartBuilder { 17 | return &multipartBuilder{w: w} 18 | } 19 | 20 | func (b *multipartBuilder) withMeta(meta OrderMeta) *multipartBuilder { 21 | b.fns = append(b.fns, func(w *multipart.Writer) error { 22 | h := make(textproto.MIMEHeader) 23 | h.Set("Content-Disposition", `form-data; name="meta"`) 24 | h.Set("Content-Type", "application/json") 25 | fw, err := w.CreatePart(h) 26 | if err != nil { 27 | return err 28 | } 29 | if _, err = fw.Write(meta.JSON()); err != nil { 30 | return err 31 | } 32 | return nil 33 | }) 34 | return b 35 | } 36 | 37 | func (b *multipartBuilder) withOrderId(id int) *multipartBuilder { 38 | b.fns = append(b.fns, func(w *multipart.Writer) error { 39 | return w.WriteField("orderId", fmt.Sprintf("%d", id)) 40 | }) 41 | return b 42 | } 43 | 44 | func (b *multipartBuilder) withFile(filename string, data []byte) *multipartBuilder { 45 | b.fns = append(b.fns, func(w *multipart.Writer) error { 46 | fw, err := w.CreateFormFile("file", filename) 47 | if err != nil { 48 | return err 49 | } 50 | if _, err = fw.Write(data); err != nil { 51 | return err 52 | } 53 | return nil 54 | }) 55 | return b 56 | } 57 | 58 | func (b *multipartBuilder) withChunkNum(current, total int) *multipartBuilder { 59 | b.fns = append(b.fns, func(w *multipart.Writer) error { 60 | if err := w.WriteField("chunk", fmt.Sprintf("%d", current)); err != nil { 61 | return err 62 | } 63 | return w.WriteField("chunks", fmt.Sprintf("%d", total)) 64 | }) 65 | return b 66 | } 67 | 68 | func (b *multipartBuilder) build() error { 69 | var err error 70 | for _, fn := range b.fns { 71 | if err = fn(b.w); err != nil { 72 | return fmt.Errorf("%w: %w", ErrMultipartBody, err) 73 | } 74 | } 75 | if err = b.w.Close(); err != nil { 76 | return fmt.Errorf("%w: %w", ErrMultipartBody, err) 77 | } 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /order-meta.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // OrderMeta - метаданные создаваемого заявления. 8 | type OrderMeta struct { 9 | Region string // Код интерактивной формы на ЕПГУ 10 | ServiceCode string // Код цели обращения услуги в ФРГУ 11 | TargetCode string // Код ОКАТО местоположения пользователя (можно передавать код ОКАТО региона, если невозможно определить точнее) 12 | } 13 | 14 | // JSON - возвращает метаданные в формате JSON. 15 | func (m *OrderMeta) JSON() []byte { 16 | return []byte(fmt.Sprintf( 17 | `{"region":"%s", "serviceCode":"%s", "targetCode":"%s"}`, 18 | m.Region, m.ServiceCode, m.TargetCode, 19 | )) 20 | } 21 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package apipgu 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | func (c *Client) requestJSON( 11 | method, 12 | endpoint, 13 | contentType, 14 | accessToken string, 15 | body io.Reader, 16 | result any, 17 | ) error { 18 | resBody, err := c.requestBody(method, endpoint, contentType, accessToken, body) 19 | if err != nil { 20 | return err 21 | } 22 | if err = json.Unmarshal(resBody, result); err != nil { 23 | return fmt.Errorf("%w: %w", ErrJSONUnmarshal, err) 24 | } 25 | 26 | return nil 27 | } 28 | 29 | func (c *Client) requestBody( 30 | method, 31 | endpoint, 32 | contentType, 33 | accessToken string, 34 | body io.Reader, 35 | ) ([]byte, error) { 36 | req, err := http.NewRequest(method, c.baseURI+endpoint, body) 37 | if err != nil { 38 | return nil, fmt.Errorf("%w: %w", ErrRequest, err) 39 | } 40 | 41 | if contentType != "" { 42 | req.Header.Set("Content-Type", contentType) 43 | } 44 | 45 | if accessToken != "" { 46 | req.Header.Set("Authorization", "Bearer "+accessToken) 47 | } 48 | 49 | c.logReq(req) 50 | 51 | res, err := c.httpClient.Do(req) 52 | if err != nil { 53 | return nil, fmt.Errorf("%w: %w", ErrRequest, err) 54 | } 55 | 56 | c.logRes(res) 57 | 58 | if res.StatusCode >= 400 || res.StatusCode == http.StatusNoContent { 59 | return nil, responseError(res) 60 | } 61 | 62 | //goland:noinspection ALL 63 | defer res.Body.Close() 64 | resBody, err := io.ReadAll(res.Body) 65 | if err != nil { 66 | return nil, fmt.Errorf("%w: %w", ErrRequest, err) 67 | } 68 | 69 | return resBody, nil 70 | } 71 | -------------------------------------------------------------------------------- /services/sfr/complex-types.go: -------------------------------------------------------------------------------- 1 | package sfr 2 | 3 | // AddressRus - российский адрес 4 | type AddressRus struct { 5 | ZipCode *string `xml:"УТ:Индекс"` 6 | Region *string `xml:"УТ:РоссийскийАдрес>УТ:Регион>УТ:Название"` 7 | District *string `xml:"УТ:РоссийскийАдрес>УТ:Район>УТ:Название"` 8 | City *string `xml:"УТ:РоссийскийАдрес>УТ:Город>УТ:Название"` 9 | Settlement *string `xml:"УТ:РоссийскийАдрес>УТ:НаселенныйПункт>УТ:Название"` 10 | Street *string `xml:"УТ:РоссийскийАдрес>УТ:Улица>УТ:Название"` 11 | House *string `xml:"УТ:РоссийскийАдрес>УТ:Дом>УТ:Номер"` 12 | Housing *string `xml:"УТ:РоссийскийАдрес>УТ:Корпус>УТ:Номер"` 13 | Building *string `xml:"УТ:РоссийскийАдрес>УТ:Строение>УТ:Номер"` 14 | Flat *string `xml:"УТ:РоссийскийАдрес>УТ:Квартира>УТ:Номер"` 15 | } 16 | 17 | // NewAddressRus - конструктор [AddressRus] 18 | func NewAddressRus() *AddressRus { 19 | return &AddressRus{} 20 | } 21 | 22 | // WithZipCode - УТ:Индекс 23 | func (a *AddressRus) WithZipCode(zipCode string) *AddressRus { 24 | a.ZipCode = &zipCode 25 | return a 26 | } 27 | 28 | // WithRegion - УТ:Регион 29 | func (a *AddressRus) WithRegion(region string) *AddressRus { 30 | a.Region = ®ion 31 | return a 32 | } 33 | 34 | // WithDistrict - УТ:Район 35 | func (a *AddressRus) WithDistrict(district string) *AddressRus { 36 | a.District = &district 37 | return a 38 | } 39 | 40 | // WithCity - УТ:Город 41 | func (a *AddressRus) WithCity(city string) *AddressRus { 42 | a.City = &city 43 | return a 44 | } 45 | 46 | // Settlement - УТ:НаселенныйПункт 47 | func (a *AddressRus) WithSettlement(settlement string) *AddressRus { 48 | a.Settlement = &settlement 49 | return a 50 | } 51 | 52 | // WithStreet - УТ:Улица 53 | func (a *AddressRus) WithStreet(street string) *AddressRus { 54 | a.Street = &street 55 | return a 56 | } 57 | 58 | // WithHouse - УТ:Дом 59 | func (a *AddressRus) WithHouse(house string) *AddressRus { 60 | a.House = &house 61 | return a 62 | } 63 | 64 | // WithHousing - УТ:Корпус 65 | func (a *AddressRus) WithHousing(housing string) *AddressRus { 66 | a.Housing = &housing 67 | return a 68 | } 69 | 70 | // WithBuilding - УТ:Строение 71 | func (a *AddressRus) WithBuilding(building string) *AddressRus { 72 | a.Building = &building 73 | return a 74 | } 75 | 76 | // WithFlat - УТ:Квартира 77 | func (a *AddressRus) WithFlat(flat string) *AddressRus { 78 | a.Flat = &flat 79 | return a 80 | } 81 | 82 | // BirthPlace - УТ:МестоРождения 83 | type BirthPlace struct { 84 | Type string `xml:"УТ:ТипМестаРождения"` // Пример: ОСОБОЕ 85 | City string `xml:"УТ:ГородРождения,omitempty"` // Пример: рп Михайловка, Ардатовский р-он 86 | Country string `xml:"УТ:СтранаРождения,omitempty"` // Пример: Российская Федерация 87 | } 88 | 89 | // УТ:МестоРождения/УТ:ТипМестаРождения 90 | const BirthPlaceSpecial = "ОСОБОЕ" 91 | 92 | // Citizenship - УТ:Гражданство/УТ:Тип 93 | type CitizenshipType string 94 | 95 | // УТ:Гражданство/УТ:Тип 96 | const ( 97 | CitizenshipRF CitizenshipType = "1" // Гражданин РФ 98 | CitizenshipForeign CitizenshipType = "2" // Иностранный гражданин 99 | CitizenshipStateless CitizenshipType = "3" // Лицо без гражданства 100 | ) 101 | 102 | // Citizenship - УТ:Гражданство 103 | type Citizenship struct { 104 | Type CitizenshipType `xml:"УТ:Тип"` // Пример: 1 105 | } 106 | 107 | // FIO - УТ:ФИО 108 | type FIO struct { 109 | LastName string `xml:"УТ:Фамилия"` // Пример: ИВАНОВ 110 | FirstName string `xml:"УТ:Имя"` // Пример: ИВАН 111 | PatronymicName string `xml:"УТ:Отчество,omitempty"` // Пример: ИВАНОВИЧ 112 | } 113 | 114 | // IdentityDoc - УТ:УдостоверяющийДокументОграниченногоСрока 115 | type IdentityDoc struct { 116 | Type string `xml:"УТ:ТипДокумента"` // Пример: ПАСПОРТ РОССИИ 117 | Series string `xml:"УТ:Серия"` // Пример: 1234 118 | Number string `xml:"УТ:Номер"` // Пример: 123456 119 | IssuedAt Date `xml:"УТ:ДатаВыдачи"` // Пример: 2010-04-13 120 | IssuedBy string `xml:"УТ:КемВыдан"` // Пример: ОВД ЛЕНИНСКОГО РАЙОНА Г. САМАРЫ 121 | IssuerCode string `xml:"УТ:КодПодразделения,omitempty"` // Пример: 123456 122 | } 123 | 124 | // УТ:УдостоверяющийДокументОграниченногоСрока/УТ:ТипДокумента 125 | const IdentityDocPassportRF = "ПАСПОРТ РОССИИ" 126 | -------------------------------------------------------------------------------- /services/sfr/complex-types_test.go: -------------------------------------------------------------------------------- 1 | package sfr 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | ) 7 | 8 | func ExampleNewAddress() { 9 | addr := NewAddressRus(). 10 | WithZipCode("397140"). 11 | WithRegion("обл Тамбовская"). 12 | WithCity("г Тамбов"). 13 | WithStreet("ул Советская"). 14 | WithHouse("д 10"). 15 | WithBuilding("стр 1"). 16 | WithFlat("кв 1") 17 | 18 | xmlBytes, err := xml.MarshalIndent(addr, "", " ") 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | fmt.Println(string(xmlBytes)) 24 | 25 | // Output: 26 | // <УТ:Индекс>397140 27 | // <УТ:РоссийскийАдрес> 28 | // <УТ:Регион> 29 | // <УТ:Название>обл Тамбовская 30 | // 31 | // <УТ:Город> 32 | // <УТ:Название>г Тамбов 33 | // 34 | // <УТ:Улица> 35 | // <УТ:Название>ул Советская 36 | // 37 | // <УТ:Дом> 38 | // <УТ:Номер>д 10 39 | // 40 | // <УТ:Строение> 41 | // <УТ:Номер>стр 1 42 | // 43 | // <УТ:Квартира> 44 | // <УТ:Номер>кв 1 45 | // 46 | // 47 | // 48 | } 49 | -------------------------------------------------------------------------------- /services/sfr/dates.go: -------------------------------------------------------------------------------- 1 | package sfr 2 | 3 | import ( 4 | "encoding/xml" 5 | "time" 6 | ) 7 | 8 | // Date - дата в формате YYYY-MM-DD 9 | type Date struct { 10 | time.Time 11 | } 12 | 13 | // NewDate - конструктор [Date]. 14 | func NewDate(year int, month time.Month, day int) Date { 15 | return Date{time.Date(year, month, day, 0, 0, 0, 0, time.UTC)} 16 | } 17 | 18 | // MarshalXML - реализация интерфейса [xml.Marshaler]. 19 | // Формат даты: YYYY-MM-DD. 20 | func (d Date) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 21 | return e.EncodeElement(d.Format("2006-01-02"), start) 22 | } 23 | 24 | // DateTime - дата и время в формате YYYY-MM-DDThh:mm:ss 25 | type DateTime struct { 26 | time.Time 27 | } 28 | 29 | // NewDateTime - конструктор [DateTime]. 30 | func NewDateTime(year int, month time.Month, day, hour, min, sec int) DateTime { 31 | return DateTime{time.Date(year, month, day, hour, min, sec, 0, time.UTC)} 32 | } 33 | 34 | // MarshalXML - реализация интерфейса [xml.Marshaler]. 35 | // Формат даты и времени: YYYY-MM-DDThh:mm:ss. 36 | func (d DateTime) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 37 | return e.EncodeElement(d.Format("2006-01-02T15:04:05"), start) 38 | } 39 | -------------------------------------------------------------------------------- /services/sfr/dates_test.go: -------------------------------------------------------------------------------- 1 | package sfr 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | ) 7 | 8 | func ExampleNewDate() { 9 | type Example struct { 10 | Date Date `xml:"Date"` 11 | } 12 | doc := Example{Date: NewDate(2019, 1, 12)} 13 | 14 | result, err := xml.Marshal(doc) 15 | if err != nil { 16 | panic(err) 17 | } 18 | fmt.Println(string(result)) 19 | 20 | // Output: 2019-01-12 21 | } 22 | 23 | func ExampleNewDateTime() { 24 | type Example struct { 25 | DateTime DateTime `xml:"DateTime"` 26 | } 27 | doc := Example{DateTime: NewDateTime(2019, 1, 12, 13, 14, 15)} 28 | 29 | result, err := xml.Marshal(doc) 30 | if err != nil { 31 | panic(err) 32 | } 33 | fmt.Println(string(result)) 34 | 35 | // Output: 2019-01-12T13:14:15 36 | } 37 | -------------------------------------------------------------------------------- /services/sfr/doc.go: -------------------------------------------------------------------------------- 1 | // Услуги и типы данных СФР. 2 | // 3 | // # Услуги 4 | // 5 | // - [github.com/ofstudio/go-api-epgu/services/sfr/10000000109-zdp] - Доставка пенсии и социальных выплат ПФР. 6 | package sfr 7 | -------------------------------------------------------------------------------- /services/sfr/snils.go: -------------------------------------------------------------------------------- 1 | package sfr 2 | 3 | import ( 4 | "encoding/xml" 5 | "errors" 6 | "fmt" 7 | "regexp" 8 | ) 9 | 10 | var ( 11 | ErrSNILSFormat = errors.New("некорректный формат СНИЛС") 12 | ErrSNILSCheck = errors.New("некорректная контрольная сумма СНИЛС") 13 | ) 14 | 15 | // SNILS - УТ:СтраховойНомер 16 | type SNILS struct { 17 | number string 18 | } 19 | 20 | var reSNILS = regexp.MustCompile(`^(\d{3})[\s-]?(\d{3})[\s-]?(\d{3})[\s-]?(\d{2})$`) 21 | 22 | // ParseSNILS - анализирует строку с номером СНИЛС и возвращает [SNILS]. 23 | // Входной формат: 11 цифр, допускаются разделители пробелы и дефисы: "000-000-000 00". 24 | // Возвращает ошибки: 25 | // - [ErrSNILSFormat] если строка не соответствует формату 26 | // - [ErrSNILSCheck] если контрольная сумма не совпадает 27 | func ParseSNILS(number string) (SNILS, error) { 28 | matches := reSNILS.FindStringSubmatch(number) 29 | if len(matches) != 5 { 30 | return SNILS{}, ErrSNILSFormat 31 | } 32 | 33 | number = matches[1] + matches[2] + matches[3] + matches[4] 34 | if number[9:11] != snilsCheckSum(number[:9]) { 35 | return SNILS{}, ErrSNILSCheck 36 | } 37 | 38 | return SNILS{number: number}, nil 39 | } 40 | 41 | // MustParseSNILS вызывает [ParseSNILS]. В случае ошибки, завершается паникой. 42 | func MustParseSNILS(number string) SNILS { 43 | snils, err := ParseSNILS(number) 44 | if err != nil { 45 | panic(err) 46 | } 47 | return snils 48 | } 49 | 50 | func snilsCheckSum(num string) string { 51 | sum := 0 52 | for i, d := range num { 53 | sum += int(d-'0') * (9 - i) 54 | 55 | } 56 | sum = sum % 101 57 | if sum == 100 { 58 | sum = 0 59 | } 60 | return fmt.Sprintf("%02d", sum) 61 | } 62 | 63 | // Number возвращает номер СНИЛС без разделителей. 64 | func (s SNILS) Number() string { 65 | return s.number 66 | } 67 | 68 | // String возвращает СНИЛС в формате "000-000-000 00". 69 | func (s SNILS) String() string { 70 | return fmt.Sprintf("%s-%s-%s %s", s.number[:3], s.number[3:6], s.number[6:9], s.number[9:]) 71 | } 72 | 73 | // MarshalXML реализует интерфейс [xml.Marshaler] для типа [SNILS]. 74 | // Формат СНИЛС: "000-000-000 00". 75 | func (s SNILS) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 76 | return e.EncodeElement(s.String(), start) 77 | } 78 | -------------------------------------------------------------------------------- /services/sfr/snils_test.go: -------------------------------------------------------------------------------- 1 | package sfr 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | func ExampleSNILS_String() { 10 | snils := MustParseSNILS("715 398 174 20") 11 | fmt.Println(snils.String()) 12 | // Output: 715-398-174 20 13 | } 14 | 15 | func ExampleSNILS_Number() { 16 | snils := MustParseSNILS("715 398 174 20") 17 | fmt.Println(snils.Number()) 18 | // Output: 71539817420 19 | } 20 | 21 | func TestParseSNILS(t *testing.T) { 22 | tests := []struct { 23 | name string 24 | number string 25 | want string 26 | wantErr error 27 | }{ 28 | {name: "успешно пробелы", number: "276 488 905 42", want: "27648890542"}, 29 | {name: "успешно дефисы", number: "002-064-585-96", want: "00206458596"}, 30 | {name: "успешно дефисы и пробел", number: "774-112-048 81", want: "77411204881"}, 31 | {name: "успешно слитно", number: "77241387515", want: "77241387515"}, 32 | {name: "ошибка формат", number: "77 241 387 515", wantErr: ErrSNILSFormat}, 33 | {name: "ошибка длина", number: "908 035 685", wantErr: ErrSNILSFormat}, 34 | {name: "ошибка символы", number: "589*088*281*65", wantErr: ErrSNILSFormat}, 35 | {name: "ошибка контрольная сумма", number: "200 746 095 00", wantErr: ErrSNILSCheck}, 36 | } 37 | for _, tt := range tests { 38 | t.Run(tt.name, func(t *testing.T) { 39 | snils, err := ParseSNILS(tt.number) 40 | if !errors.Is(err, tt.wantErr) { 41 | t.Errorf("ParseSNILS() error = %v, wantErr %v", err, tt.wantErr) 42 | return 43 | } 44 | got := snils.Number() 45 | if got != tt.want { 46 | t.Errorf("ParseSNILS() got = %v, want %v", got, tt.want) 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /services/sfr/xmlns.go: -------------------------------------------------------------------------------- 1 | package sfr 2 | 3 | type Namespaces struct { 4 | NS string `xml:"xmlns,attr,omitempty"` 5 | NS2 string `xml:"xmlns:ns2,attr,omitempty"` 6 | AF string `xml:"xmlns:АФ,attr,omitempty"` 7 | UT string `xml:"xmlns:УТ,attr,omitempty"` 8 | VZL string `xml:"xmlns:ВЗЛ,attr,omitempty"` 9 | } 10 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/README.md: -------------------------------------------------------------------------------- 1 | # Услуга "Доставка пенсии и социальных выплат ПФР" 2 | 3 | `github.com/ofstudio/go-api-epgu/services/sfr/10000000109-zdp` 4 | 5 | ## Параметры услуги 6 | 7 | | Параметр | Значение | 8 | |-------------------------------------------------|------------------------| 9 | | `eServiceCode` (код услуги) | 10000000109 | 10 | | `serviceTargetCode` (идентификатор цели услуги) | -10000000109 | 11 | | Идентификатор цели оказания госуслуги по ФРГУ | 10002953957 | 12 | | Категории получателей | ФЛ с подтвержденной УЗ | 13 | | Подписание | Не требуется | 14 | | Возможность отмены | Не предусмотрена | 15 | 16 | 17 | ## URL формы 18 | 19 | - Тестовая среда SVCDEV: https://svcdev-beta.test.gosuslugi.ru/600109/1/form 20 | - Продуктивная среда: https://www.gosuslugi.ru/600109/1/form 21 | 22 | Отправка заявлений происходит с использованием вида сведений 23 | «[Приём заявления о доставке пенсии](https://lkuv.gosuslugi.ru/paip-portal/#/inquiries/card/63730133-ff80-11eb-ba23-33408f10c8dc)» 24 | 25 | ## Примеры 26 | - [Создание заявления и загрузка архива по частям](/examples/order-push-chunked/main.go) 27 | 28 | ## Примечание 29 | 30 | Предназначено для демонстрации. Реализованы не все возможности услуги, 31 | а также отсутствуют проверки на полноту и валидность данных. 32 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/applicant.go: -------------------------------------------------------------------------------- 1 | package zdp 2 | 3 | import "github.com/ofstudio/go-api-epgu/services/sfr" 4 | 5 | // Applicant - анкета заявителя структуры [ZDP] 6 | type Applicant struct { 7 | FIO sfr.FIO `xml:"УТ:ФИО"` 8 | Sex string `xml:"УТ:Пол"` // Пример: М 9 | BirthDate sfr.Date `xml:"УТ:ДатаРождения"` // Пример: 1960-04-13 10 | SNILS sfr.SNILS `xml:"УТ:СтраховойНомер"` // Пример: 000-666-666 99 11 | BirthPlace sfr.BirthPlace `xml:"УТ:МестоРождения"` // Место рождения 12 | Citizenship sfr.Citizenship `xml:"УТ:Гражданство"` // Пример: 1 13 | AddressFact *sfr.AddressRus `xml:"УТ:АдресФактический,omitempty"` 14 | AddressReg *sfr.AddressRus `xml:"УТ:АдресРегистрации,omitempty"` 15 | AddressResidence *sfr.AddressRus `xml:"УТ:АдресПребывания,omitempty"` 16 | Phone string `xml:"УТ:Телефоны>УТ:Телефон"` // Пример: 89123456789 17 | Email string `xml:"УТ:АдресЭлПочты,omitempty"` // Пример: ivanov@mail.ru 18 | IdentityDoc sfr.IdentityDoc `xml:"УТ:УдостоверяющийДокументОграниченногоСрока"` 19 | } 20 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/delivery-info.go: -------------------------------------------------------------------------------- 1 | package zdp 2 | 3 | import "github.com/ofstudio/go-api-epgu/services/sfr" 4 | 5 | // DeliveryLocation - место доставки пенсии из структуры [DeliveryInfo] 6 | type DeliveryLocation int 7 | 8 | const ( 9 | DeliveryBankOrHome DeliveryLocation = 1 // Банк, доставка домой (в спецификации опечатка? Указано 3) 10 | DeliveryCashDesk DeliveryLocation = 2 // В кассе Почты России, в кассе (в спецификации опечатка? Указано 4) 11 | ) 12 | 13 | // DeliveryMethod - способ доставки пенсии из структуры [DeliveryInfo] 14 | type DeliveryMethod int 15 | 16 | const ( 17 | DeliveryPostOffice DeliveryMethod = 1 // Почта России 18 | DeliveryBank DeliveryMethod = 2 // Банк 19 | DeliveryOther DeliveryMethod = 3 // Другая организация 20 | ) 21 | 22 | // DeliveryRecipient - получатель пенсии из структуры [DeliveryInfo] 23 | type DeliveryRecipient int 24 | 25 | const ( 26 | DeliveryMyself DeliveryRecipient = 1 // Себе 27 | DeliveryMinorChild DeliveryRecipient = 2 // Несовершеннолетнему ребенку 28 | ) 29 | 30 | // DeliveryPickup - способ вручения пенсии из структуры [DeliveryInfo] 31 | type DeliveryPickup int 32 | 33 | const ( 34 | DeliveryOrganisation DeliveryPickup = 1 // В кассе Почты России, в кассе 35 | DeliveryHome DeliveryPickup = 2 // Доставка домой 36 | ) 37 | 38 | // DeliveryInfo - сведения о доставке пенсии из структуры [ZDP] 39 | type DeliveryInfo struct { 40 | Date sfr.Date `xml:"ДатаДоставки"` // Пример: 2023-04-13 41 | Location DeliveryLocation `xml:"МестоДоставки"` // Пример: 1 42 | Method DeliveryMethod `xml:"СпособДоставки"` // Пример: 2 43 | Recipient DeliveryRecipient `xml:"Получатель"` // Пример: 1 44 | Pickup DeliveryPickup `xml:"СпособВручения,omitempty"` // Пример: 1 45 | Organisation string `xml:"НаименованиеОрганизации,omitempty"` // Пример: АЛТАЙСКИЙ РФ АО "РОССЕЛЬХОЗБАНК" г Барнаул 46 | AccountNumber string `xml:"НомерСчета,omitempty"` // Пример: 40817810000000000001 47 | Address sfr.AddressRus `xml:"Адрес"` // Адрес доставки 48 | } 49 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/doc.go: -------------------------------------------------------------------------------- 1 | // Услуга "Доставка пенсии и социальных выплат ПФР" 2 | // 3 | // # Параметры услуги 4 | // 5 | // - eServiceCode (код услуги): 10000000109 6 | // - serviceTargetCode (идентификатор цели услуги): -10000000109 7 | // - Идентификатор цели оказания госуслуги по ФРГУ: 10002953957 8 | // - Категории получателей: ФЛ с подтвержденной УЗ 9 | // - Подписание: не требуется 10 | // - Возможность отмены: не предусмотрена 11 | // 12 | // # URL формы 13 | // 14 | // - Тестовая среда SVCDEV: https://svcdev-beta.test.gosuslugi.ru/600109/1/form 15 | // - Продуктивная среда: https://www.gosuslugi.ru/600109/1/form 16 | // 17 | // Отправка заявлений происходит с использованием вида сведений «Приём заявления о доставке пенсии»: 18 | // https://lkuv.gosuslugi.ru/paip-portal/#/inquiries/card/63730133-ff80-11eb-ba23-33408f10c8dc 19 | // 20 | // # Примеры 21 | // 22 | // - [github.com/ofstudio/go-api-epgu/examples/order-push-chunked] — создание заявления и загрузка архива по частям 23 | // 24 | // # Примечание 25 | // 26 | // Предназначено для демонстрации. Реализованы не все возможности услуги, 27 | // а также отсутствуют проверки на полноту и валидность данных. 28 | package zdp 29 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/doc/Specifikaciya_API_EPGU_Dostavka_pensii_i_socialnyh_vyplat_PFR_600109_1.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ofstudio/go-api-epgu/81a542a04610583ea43cc814aa66791b9f2bcfc4/services/sfr/zdp-10000000109/doc/Specifikaciya_API_EPGU_Dostavka_pensii_i_socialnyh_vyplat_PFR_600109_1.docx -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/doc/req-example.xml: -------------------------------------------------------------------------------- 1 | 2 | <ЭДПФР 3 | xmlns="http://пф.рф/ВЗЛ/ЗДП/2016-04-15" 4 | xmlns:date="http://exslt.org/dates-and-times" 5 | xmlns:Вх_ВЗЛ="http://пф.рф/ВЗЛ/типы/Входящие/2014-01-01" 6 | xmlns:АФ="http://пф.рф/АФ" 7 | xmlns:УТ="http://пф.рф/унифицированныеТипы/2014-01-01" 8 | xmlns:ВЗЛ="http://пф.рф/ВЗЛ/типы/2014-01-01" 9 | xmlns:pgufg="http://idecs.atc.ru/pgufg/ws/fgapc/" 10 | > 11 | 12 | <ЗДП> 13 | 14 | <ВЗЛ:ТерОрган>Клиентская служба (на правах отдела) в Ленинском и Самарском районах городского округа Самара 15 | <ВЗЛ:ДатаЗаполнения>2023-04-13 16 | 17 | <Анкета> 18 | 19 | <УТ:ФИО> 20 | <УТ:Фамилия>Двадцатый 21 | <УТ:Имя>Иван 22 | <УТ:Отчество>Сергеевич 23 | 24 | 25 | <УТ:Пол>М 26 | <УТ:ДатаРождения>1990-02-10 27 | <УТ:СтраховойНомер>000-666-666 99 28 | 29 | <УТ:МестоРождения> 30 | <УТ:ТипМестаРождения>ОСОБОЕ 31 | <УТ:ГородРождения>рп Михайловка, Ардатовский р-он 32 | <УТ:СтранаРождения>Российская Федерация 33 | 34 | 35 | <УТ:Гражданство> 36 | <УТ:Тип>1 37 | 38 | 39 | <УТ:АдресФактический> 40 | <УТ:Индекс>443001 41 | <УТ:РоссийскийАдрес> 42 | <УТ:Регион> 43 | <УТ:Название>Самарская 44 | 45 | <УТ:Город> 46 | <УТ:Название>Самара 47 | 48 | <УТ:Улица> 49 | <УТ:Название>Ярмарочная 50 | 51 | <УТ:Дом> 52 | <УТ:Номер>55 53 | 54 | <УТ:Квартира> 55 | <УТ:Номер> 56 | 57 | 58 | 59 | 60 | <УТ:Телефоны> 61 | <УТ:Телефон>89198358345 62 | 63 | 64 | <УТ:АдресЭлПочты>testgosuslugi+20@gmail.com 65 | 66 | <УТ:УдостоверяющийДокументОграниченногоСрока> 67 | <УТ:ТипДокумента>ПАСПОРТ РОССИИ 68 | <УТ:Серия>8914 69 | <УТ:Номер>002457 70 | <УТ:ДатаВыдачи>2021-03-01 71 | <УТ:КемВыдан>пффпфывы14124 72 | 73 | 74 | 75 | 76 | <СведенияОДоставке> 77 | 78 | <ДатаДоставки>2023-04-01 79 | <МестоДоставки>3 80 | <Получатель>1 81 | <СпособДоставки>2 82 | 83 | <НаименованиеОрганизации>АЛТАЙСКИЙ РФ АО "РОССЕЛЬХОЗБАНК" г Барнаул 84 | <НомерСчета>40702810038050103285 85 | 86 | <Адрес> 87 | <УТ:Индекс>443001 88 | <УТ:РоссийскийАдрес> 89 | <УТ:Регион> 90 | <УТ:Название>Самарская 91 | 92 | <УТ:Город> 93 | <УТ:Название>Самара 94 | 95 | <УТ:Улица> 96 | <УТ:Название>Ярмарочная 97 | 98 | <УТ:Дом> 99 | <УТ:Номер>55 100 | 101 | <УТ:Квартира> 102 | <УТ:Номер> 103 | 104 | 105 | 106 | 107 | 108 | 109 | <ПризнакОзнакомления>1 110 | 111 | 112 | 113 | <СлужебнаяИнформация> 114 | <АФ:GUID>8f8b7e4b-dec8-4dac-8a02-3dcde44d4fb2 115 | <АФ:ДатаВремя>2023-04-13T14:48:03 116 | <НомерВнешний>2662455582 117 | <ДатаПодачи>2023-04-13 118 | 119 | 120 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/doc/trans-example.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 00066666699 9 | 10 | 2662455582 11 | 2023-04-13 12 | 13 | 36401383000 14 | 45344000 15 | 2662455582 16 | req_8f8b7e4b-dec8-4dac-8a02-3dcde44d4fb2.xml 17 | 10002953957 18 | 19 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/edpfr.go: -------------------------------------------------------------------------------- 1 | package zdp 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/ofstudio/go-api-epgu/services/sfr" 7 | ) 8 | 9 | // EDPFR - корневой элемент документа заявления 10 | type EDPFR struct { 11 | XMLName xml.Name `xml:"ЭДПФР"` 12 | sfr.Namespaces 13 | ZDP ZDP `xml:"ЗДП"` 14 | ServiceInfo ServiceInfo `xml:"СлужебнаяИнформация"` 15 | } 16 | 17 | // ServiceInfo - служебная информация структуры [EDPFR] 18 | type ServiceInfo struct { 19 | GUID string `xml:"АФ:GUID"` // Пример: 8f8b7e4b-dec8-4dac-8a02-3dcde44d4fb2 20 | DateTime sfr.DateTime `xml:"АФ:ДатаВремя"` // Пример: 2023-04-13T14:48:03 21 | ExternalRegistrationNumber string `xml:"НомерВнешний"` // Пример: 2662455582 22 | ApplicationDate sfr.Date `xml:"ДатаПодачи"` // Пример: 2023-04-13 23 | } 24 | 25 | var edpfrNamespaces = sfr.Namespaces{ 26 | NS: "http://пф.рф/ВЗЛ/ЗДП/2016-04-15", 27 | AF: "http://пф.рф/АФ", 28 | UT: "http://пф.рф/унифицированныеТипы/2014-01-01", 29 | VZL: "http://пф.рф/ВЗЛ/типы/2014-01-01", 30 | } 31 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/request.go: -------------------------------------------------------------------------------- 1 | package zdp 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/ofstudio/go-api-epgu/services/sfr" 7 | ) 8 | 9 | // Request - корневой элемент транспортного конверта заявления 10 | type Request struct { 11 | XMLName xml.Name `xml:"ns2:Request"` 12 | sfr.Namespaces 13 | 14 | SNILS string `xml:"SNILS"` // Пример: 11702657331 15 | ExternalRegistrationNumber string `xml:"ExternalRegistrationData>ExternalRegistrationNumber"` // Пример: 3500274591 16 | ExternalRegistrationDate sfr.Date `xml:"ExternalRegistrationData>ExternalRegistrationDate"` // Пример: 2023-04-13 17 | OKATO string `xml:"OKATO"` // Пример: 92401379000 18 | OKTMO string `xml:"OKTMO"` // Пример: 92701000001 19 | MFCCode string `xml:"MFCCode"` // Значение не проверяется 20 | ApplicationFileName string `xml:"ApplicationFileName"` // Пример: req_2f1ee59c-a531-42f6-690e-c780dd2e345e.xml 21 | FRGUTargetId string `xml:"ns2:FRGUTargetId"` // 10002953957 22 | } 23 | 24 | var requestNamespaces = sfr.Namespaces{ 25 | NS: "urn://cmv.pfr.ru/types/1.0.1", 26 | NS2: "urn://cmv.pfr.ru/zdp/1.0.1", 27 | } 28 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/service.go: -------------------------------------------------------------------------------- 1 | package zdp 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "time" 7 | 8 | apipgu "github.com/ofstudio/go-api-epgu" 9 | "github.com/ofstudio/go-api-epgu/services/sfr" 10 | "github.com/ofstudio/go-api-epgu/utils" 11 | ) 12 | 13 | const ( 14 | FRGUTargetId = "10002953957" // Идентификатор цели оказания госуслуги по ФРГУ 15 | ServiceCode = "10000000109" // Идентификатор формы заявления 16 | TargetCode = "-10000000109" // Идентификатор цели 17 | MFCCode = "API ЕПГУ" 18 | ) 19 | 20 | var ( 21 | errService = fmt.Errorf("%w: %s", apipgu.ErrService, "10000000109-sfr-zdp") 22 | errXMLMarshal = fmt.Errorf("%w: %w", errService, apipgu.ErrXMLMarshal) 23 | errGUID = fmt.Errorf("%w: %w", errService, apipgu.ErrGUID) 24 | ) 25 | 26 | // Service - Услуга "Доставка пенсии и социальных выплат ПФР" 27 | type Service struct { 28 | EDPFR 29 | Request 30 | debug bool 31 | logger utils.Logger 32 | } 33 | 34 | // NewService - конструктор [Service]. 35 | // Принимает коды ОКАТО и ОКТМО заявителя, а также данные заявления. 36 | // В качестве ОКАТО и ОКТМО можно использовать коды региона заявителя. Напр.: "92000000000". 37 | // 38 | // В случае ошибки возвращает цепочку из apipgu.ErrService и apipgu.ErrGUID. 39 | func NewService(okato, oktmo string, zdp ZDP) (*Service, error) { 40 | now := nowFunc() 41 | guid, err := guidFunc() 42 | if err != nil { 43 | return nil, fmt.Errorf("%w: %w", errGUID, err) 44 | } 45 | 46 | if zdp.FillingDate.IsZero() { 47 | zdp.FillingDate.Time = now 48 | } 49 | 50 | if zdp.DeliveryInfo.Date.IsZero() { 51 | zdp.DeliveryInfo.Date.Time = now 52 | } 53 | 54 | return &Service{ 55 | EDPFR: EDPFR{ 56 | Namespaces: edpfrNamespaces, 57 | ZDP: zdp, 58 | ServiceInfo: ServiceInfo{ 59 | GUID: guid, 60 | DateTime: sfr.DateTime{Time: now}, 61 | ApplicationDate: sfr.Date{Time: now}, 62 | }, 63 | }, 64 | Request: Request{ 65 | Namespaces: requestNamespaces, 66 | SNILS: zdp.Applicant.SNILS.Number(), 67 | ExternalRegistrationDate: sfr.Date{Time: now}, 68 | OKATO: okato, 69 | OKTMO: oktmo, 70 | MFCCode: MFCCode, 71 | FRGUTargetId: FRGUTargetId, 72 | }, 73 | }, nil 74 | } 75 | 76 | // WithDebug - включает логирование создаваемых XML-файлов и метаданных услуги. 77 | // Формат лога: 78 | // 79 | // >>> 10000000109-sfr-zdp: {имя файла} 80 | // ... 81 | // {содержимое файла} 82 | // ... 83 | func (s *Service) WithDebug(logger utils.Logger) *Service { 84 | s.logger = logger 85 | s.debug = logger != nil 86 | return s 87 | } 88 | 89 | // Meta - возвращает метаданные услуги. 90 | func (s *Service) Meta() apipgu.OrderMeta { 91 | meta := apipgu.OrderMeta{ 92 | Region: s.Request.OKATO, 93 | ServiceCode: ServiceCode, 94 | TargetCode: TargetCode, 95 | } 96 | s.logData("meta", meta.JSON()) 97 | return meta 98 | } 99 | 100 | // Archive - возвращает архив с файлом заявления и транспортным файлом. 101 | // В случае ошибки возвращает цепочку из apipgu.ErrService и следующих возможных ошибок: 102 | // - apipgu.ErrXMLMarshal - ошибка создания XML 103 | // - apipgu.ErrZip - ошибка создания zip-архива 104 | func (s *Service) Archive(orderId int) (*apipgu.Archive, error) { 105 | var ( 106 | archiveFileName = fmt.Sprintf("%d-archive", orderId) 107 | reqFileName = fmt.Sprintf("req_%s.xml", s.EDPFR.ServiceInfo.GUID) 108 | transFileName = fmt.Sprintf("trans_%s.xml", s.EDPFR.ServiceInfo.GUID) 109 | ) 110 | 111 | s.Request.ApplicationFileName = reqFileName 112 | s.Request.ExternalRegistrationNumber = fmt.Sprintf("%d", orderId) 113 | s.EDPFR.ServiceInfo.ExternalRegistrationNumber = s.Request.ExternalRegistrationNumber 114 | 115 | reqXML, err := xml.MarshalIndent(s.EDPFR, "", " ") 116 | if err != nil { 117 | return nil, fmt.Errorf("%w: %w", errXMLMarshal, err) 118 | } 119 | s.logData(reqFileName, reqXML) 120 | 121 | transXML, err := xml.MarshalIndent(s.Request, "", " ") 122 | if err != nil { 123 | return nil, fmt.Errorf("%w: %w", errXMLMarshal, err) 124 | } 125 | s.logData(transFileName, transXML) 126 | 127 | reqFile := apipgu.ArchiveFile{Filename: reqFileName, Data: append([]byte(xml.Header), reqXML...)} 128 | transFile := apipgu.ArchiveFile{Filename: transFileName, Data: append([]byte(xml.Header), transXML...)} 129 | 130 | archive, err := apipgu.NewArchive(archiveFileName, reqFile, transFile) 131 | if err != nil { 132 | return nil, fmt.Errorf("%w: %w", errService, err) 133 | } 134 | 135 | return archive, nil 136 | } 137 | 138 | func (s *Service) logData(name string, data []byte) { 139 | if s.debug { 140 | s.logger.Print(fmt.Sprintf(">>> 10000000109-sfr-zdp: %s\n%s\n", name, string(data))) 141 | } 142 | } 143 | 144 | var ( 145 | guidFunc = utils.GUID 146 | nowFunc = time.Now 147 | ) 148 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/xsd/cmv-types-1.0.1.xsd: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | СНИЛС 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | Код ОКТМО 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | Код ОКАТО 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | Тип данных о вложениях 83 | 84 | 85 | 86 | 87 | 88 | 89 | Данные о вложении 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | Унифицированный тип для подачи заявления из СМЭВ в ПФР 100 | 101 | 102 | 103 | 104 | 105 | 106 | СНИЛС заявителя 107 | 108 | 109 | 110 | 111 | 112 | 113 | Регистрационные данные обращения в МФЦ/ЕПГУ 114 | 115 | 116 | 117 | 118 | 119 | 120 | Код ОКАТО 121 | 122 | 123 | 124 | 125 | 126 | 127 | Код ОКТМО 128 | 129 | 130 | 131 | 132 | 133 | 134 | Код МФЦ 135 | 136 | 137 | 138 | 139 | 140 | 141 | Имя файла заявления, передаваемого в качестве вложения к сообщению 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | Унифицированный тип для ответа сервисов подачи заявлений 152 | 153 | 154 | 155 | 156 | 157 | 158 | Регистрационный номер обращения, переданный в запросе 159 | 160 | 161 | 162 | 163 | 164 | 165 | Код статуса обработки 166 | 167 | 168 | 169 | 170 | 171 | 172 | Комментарий к статусу 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | Тип описания данных об одном вложении 183 | 184 | 185 | 186 | 187 | 188 | 189 | Имя файла вложения (то же, что в блоке вложений в конверте СМЭВ) 190 | 191 | 192 | 193 | 194 | 195 | 196 | Код типа вложения 197 | 198 | 199 | 200 | 201 | 202 | 203 | Описание вложения 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | Регистрационные данные во внешней системе 214 | 215 | 216 | 217 | 218 | 219 | 220 | Регистрационный номер обращения, присвоенный в МФЦ/ЕПГУ 221 | 222 | 223 | 224 | 225 | 226 | 227 | Дата и время регистрации обращения в МФЦ/ЕПГУ 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/xsd/schemas.xsd: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Сообщение-запрос 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Идентификатор цели оказания госуслуги по ФРГУ, в рамках которой идёт обращение к виду сведений 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | Сообщение-ответ 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/xsd/ЗДП_2016-04-15.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Схема проверки документа содержащего заявление о доставке пенсии 9 | 10 | 11 | 12 | Корневой элемент. Электронный документ ЗДП (Заявление о доставке пенсии). Содержит сведения самого документа и служебную информацию об электронном документе. 13 | 14 | 15 | 16 | 17 | 18 | Заявление о доставке пенсии 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Анкетные данные застрахованного лица 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | Адрес электронной почты 35 | 36 | 37 | 38 | 39 | Документ, удостоверяющий личность 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Сведения о представителе застрахованного лица 50 | 51 | 52 | 53 | 54 | Указываются сведения о последнем адресе проживания заявителя до выезда за пределы Российской Федерации. Заполняется в случае, если заявитель проживает за пределами РФ. 55 | 56 | 57 | 58 | 59 | 60 | Группа элементов со сведениями об адресах. 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | Сведения о доставке пенсии(п.3 заявления) 69 | 70 | 71 | 72 | 73 | 74 | Дата начала доставки пенсии 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | По какому месту доставлять пенсию: 1 - по месту жительства; 2 - по месту пребывания; 3 - по месту фактического проживания; 4 - по месту нахождения организации (заполняется при подаче заявления представителем-юридическим лицом) 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | Группа элементов со сведениями о способе доставки пенсии 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | Сведения о доставке пенсии до заключения договора предусмотренного частью 14 статьи 21 Федерального закона от 28.12.2013 № 400-ФЗ «О страховых пенсиях». Заполняется в случае выбора организации для доставки пенсии, с которой не заключен указанный договор (п.4 заявления) 106 | 107 | 108 | 109 | 110 | 111 | Группа элементов со сведениями о способе доставки пенсии 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | Сведения об ознакомлении заявителя с положениями п 5. заявления. 120 | 1 - ознакомлен, 0 - не ознакомлен. 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | Электронная подпись (ЭП) в соответствии со спецификацией XMLDsig 131 | 132 | 133 | 134 | 135 | Служебная информация об электронном документе 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | Номер по журналу регистрации, присвоенный в момент подачи заявления (уведомления) 144 | 145 | 146 | 147 | 148 | Дата подачи заявления 149 | 150 | 151 | 152 | 153 | Ошибки, выявленные при визуальном контроле. 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | Список документов предоставляемых застрахованным лицом 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | Группа, содержащая сведения о доставке почты заявителю 172 | 173 | 174 | 175 | 176 | Кому доставлять пенсию: 1 - пенсионеру; 2 - представителю 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | Способ доставки пенсии: 1 - через организацию почтовой связи; 2 - через кредитную организацию; 3 - через иную организацию, занимающуюся доставкой пенсии 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | Наименование кредитной, либо иной организации, занимающейся доставкой пенсии 200 | 201 | 202 | 203 | 204 | Способ вручения пенсии: 1 - путем вручения в кассе организации; 2 - путем вручения на дому. Заполняется при выборе способа доставки через организацию почтовой связи или иную организацию, занимающеюся доставкой пенсии 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | Адрес, по которому должна доставляться пенсия 216 | 217 | 218 | 219 | 220 | Номер счета получателя пенсии в указанной кредитной организации 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/xsd/ТипыАФ_2018-12-07.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Типы данных, расширяющие пространство имен http://пф.рф/АФ 5 | Рекомендуемый префикс для пространства имен http://пф.рф/АФ/2018-12-07 - АФ5 6 | 7 | 8 | 9 | 10 | Глобальный идентификатор электронного документа, присваиваемый составителем. Относится к зоне идентификации документа. Реализация спецификации стандарта http://www.ietf.org/rfc/rfc4122.txt 11 | 12 | 13 | 14 | 15 | Глобальный идентификатор электронного документа, в ответ на который сформирован документ. Относится к зоне идентификации документа. Реализация спецификации стандарта http://www.ietf.org/rfc/rfc4122.txt 16 | 17 | 18 | 19 | 20 | Дата и время формирования электронного документа 21 | 22 | 23 | 24 | 25 | Наименование программы подготовки электронного документа 26 | 27 | 28 | 29 | 30 | Тип для представления служебной информации о составителе, дате и времени составления, идентификационной и иной информации об электронном документе. 31 | 32 | 33 | 34 | 35 | Глобальный идентификатор электронного документа, присваиваемый составителем. Относится к зоне идентификации документа. Реализация спецификации стандарта http://www.ietf.org/rfc/rfc4122.txt 36 | 37 | 38 | 39 | 40 | Глобальный идентификатор электронного документа, в ответ на который сформирован документ. Относится к зоне идентификации документа. Реализация спецификации стандарта http://www.ietf.org/rfc/rfc4122.txt 41 | 42 | 43 | 44 | 45 | Дата и время формирования электронного документа 46 | 47 | 48 | 49 | 50 | 51 | 52 | Тип для представления служебной информации о составителе, дате и времени составления, идентификационной и иной информации об электронном документе. 53 | 54 | 55 | 56 | 57 | Тип для представления служебной информации о составителе, дате и времени составления, идентификационной и иной информации об электронном документе 58 | 59 | 60 | 61 | 62 | 63 | 64 | Тип для представления служебной информации со сведениями о программе подготовки электронного документа. 65 | 66 | 67 | 68 | 69 | 70 | 71 | Сведения о программе, в которой был подготовлен документ 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | Номер версии 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | Тип, представляющий правило заполнения пути к xml-файлу 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | Тип, представляющий правило заполнения пути к xls(x)-файлу 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | Тип, задающий правило заполнения пути к XSD схеме 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | Тип, представляющий правило заполнения пути к файлу xslt-преобразования 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | Тип, представляющий правило заполнения пути к файлу MS Word 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | Код результата проверки 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | Тип, используемый для описания переменной 143 | 144 | 145 | 146 | 147 | Краткое описание для чего вводится переменная 148 | 149 | 150 | 151 | 152 | Принимаемое переменной значение 153 | 154 | 155 | 156 | 157 | 158 | 159 | Тип для описания временных периодов, определяющихся датой и временем начала периода ("с") и датой и временем окончания периода ("по") 160 | 161 | 162 | 163 | 164 | Дата и время начала периода ("с") 165 | 166 | 167 | 168 | 169 | Дата и время окончания периода ("по") 170 | 171 | 172 | 173 | 174 | 175 | 176 | Тип для представления служебной информации со сведениями о реквизитах электронной подписи. 177 | 178 | 179 | 180 | 181 | 182 | 183 | Сведения о реквизитах электронной подписи 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | Тип для представления сведений о реквизитах электронной подписи. 193 | 194 | 195 | 196 | 197 | Основное название 198 | 199 | 200 | 201 | 202 | ФИО владельца сертификата ЭП (кому выдан сертификат) 203 | 204 | 205 | 206 | 207 | Издатель сертификата ЭП 208 | 209 | 210 | 211 | 212 | Идентификатор сертификата ЭП 213 | 214 | 215 | 216 | 217 | Срок действия сертификата ЭП 218 | 219 | 220 | 221 | 222 | Срок действия закрытого ключа ЭП 223 | 224 | 225 | 226 | 227 | 228 | 229 | Сведения о реквизитах электронной подписи 230 | 231 | 232 | 233 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/xsd/ТипыОбщие.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Тип для представления служебной информации о составителе, дате и времени составления, идентификационной и иной информации об электронном документе. 6 | 7 | 8 | 9 | 10 | Глобальный идентификатор электронного документа, присваиваемый составителем. Относится к зоне идентификации документа. Реализация спецификации стандарта http://www.ietf.org/rfc/rfc4122.txt 11 | 12 | 13 | 14 | 15 | Глобальный идентификатор электронного документа, в ответ на который сформирован документ. Относится к зоне идентификации документа. Реализация спецификации стандарта http://www.ietf.org/rfc/rfc4122.txt 16 | 17 | 18 | 19 | 20 | Дата и время формирования электронного документа 21 | 22 | 23 | 24 | 25 | 26 | 27 | Глобальный идентификатор электронного документа, присваиваемый составителем. Относится к зоне идентификации документа. Реализация спецификации стандарта http://www.ietf.org/rfc/rfc4122.txt 28 | 29 | 30 | 31 | 32 | Глобальный идентификатор электронного документа, в ответ на который сформирован документ. Относится к зоне идентификации документа. Реализация спецификации стандарта http://www.ietf.org/rfc/rfc4122.txt 33 | 34 | 35 | 36 | 37 | Дата и время формирования электронного документа 38 | 39 | 40 | 41 | 42 | Тип, используемый для представления правила заполнения глобального идентификатора из пространства http://microsoft.com/wsdl/types/. Реализация спецификации стандарта http://www.ietf.org/rfc/rfc4122.txt 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | Номер версии 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | Тип, представляющий правило заполнения пути к xml-файлу 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | Тип, представляющий правило заполнения пути к xls(x)-файлу 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | Тип, задающий правило заполнения пути к XSD схеме 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | Код результата проверки 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | Тип, используемый для описания переменной 94 | 95 | 96 | 97 | 98 | Краткое описание для чего вводится переменная 99 | 100 | 101 | 102 | 103 | Принимаемое переменной значение 104 | 105 | 106 | 107 | 108 | 109 | 110 | Тип, представляющий правило заполнения пути к файлу xslt-преобразования 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /services/sfr/zdp-10000000109/zdp.go: -------------------------------------------------------------------------------- 1 | package zdp 2 | 3 | import "github.com/ofstudio/go-api-epgu/services/sfr" 4 | 5 | // ZDP - данные заявления о доставке пенсии 6 | type ZDP struct { 7 | TOSFR string `xml:"ВЗЛ:ТерОрган"` // Пример: Клиентская служба в Ново-Савиновском районе Казани 8 | FillingDate sfr.Date `xml:"ВЗЛ:ДатаЗаполнения"` // Пример: 2023-04-13 9 | Applicant Applicant `xml:"Анкета"` // Анкета заявителя 10 | DeliveryInfo DeliveryInfo `xml:"СведенияОДоставке"` // Сведения о доставке пенсии 11 | Confirmation int `xml:"ПризнакОзнакомления"` // Пример: 1 12 | } 13 | -------------------------------------------------------------------------------- /utils/doc.go: -------------------------------------------------------------------------------- 1 | // Утилиты. 2 | package utils 3 | -------------------------------------------------------------------------------- /utils/guid.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "io" 7 | ) 8 | 9 | func GUID() (string, error) { 10 | uuid := make([]byte, 16) 11 | _, err := io.ReadFull(rand.Reader, uuid) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | uuid[6] = (uuid[6] & 0x0f) | 0x40 // Version 4 17 | uuid[8] = (uuid[8] & 0x3f) | 0x80 // Variant is 10 18 | 19 | dst := make([]byte, 36) 20 | hex.Encode(dst[0:8], uuid[0:4]) 21 | dst[8] = '-' 22 | hex.Encode(dst[9:13], uuid[4:6]) 23 | dst[13] = '-' 24 | hex.Encode(dst[14:18], uuid[6:8]) 25 | dst[18] = '-' 26 | hex.Encode(dst[19:23], uuid[8:10]) 27 | dst[23] = '-' 28 | hex.Encode(dst[24:], uuid[10:]) 29 | 30 | return string(dst), nil 31 | } 32 | -------------------------------------------------------------------------------- /utils/guid_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "testing" 4 | 5 | func TestGUID(t *testing.T) { 6 | t.Run("length", func(t *testing.T) { 7 | guid, err := GUID() 8 | if err != nil { 9 | t.Fatal(err) 10 | } 11 | if len(guid) != 36 { 12 | t.Errorf("expected length 36, got %d", len(guid)) 13 | } 14 | }) 15 | t.Run("format", func(t *testing.T) { 16 | guid, err := GUID() 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | if guid[8] != '-' || guid[13] != '-' || guid[18] != '-' || guid[23] != '-' { 21 | t.Error("expected dashes at positions 8, 13, 18 and 23") 22 | } 23 | }) 24 | t.Run("version", func(t *testing.T) { 25 | guid, err := GUID() 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | if guid[14] != '4' { 30 | t.Error("expected version 4") 31 | } 32 | }) 33 | t.Run("variant", func(t *testing.T) { 34 | guid, err := GUID() 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | if guid[19] != '8' && guid[19] != '9' && guid[19] != 'a' && guid[19] != 'b' { 39 | t.Error("expected variant 8, 9, a or b") 40 | } 41 | }) 42 | t.Run("uniqueness", func(t *testing.T) { 43 | guid1, err := GUID() 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | guid2, err := GUID() 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | if guid1 == guid2 { 52 | t.Error("expected unique GUIDs") 53 | } 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /utils/log-http.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httputil" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | // Logger - интерфейс для логирования HTTP-запросов и ответов. 12 | type Logger interface { 13 | Print(...any) 14 | } 15 | 16 | // LogReq логирует HTTP-запрос. 17 | func LogReq(req *http.Request, logger Logger) { 18 | if logger == nil { 19 | return 20 | } 21 | dump, _ := httputil.DumpRequestOut(req, true) 22 | logger.Print(">>> Request to ", req.URL.String(), "\n", sanitize(string(dump)), "\n\n") 23 | } 24 | 25 | // LogRes логирует HTTP-запрос. 26 | func LogRes(res *http.Response, logger Logger) { 27 | if logger == nil { 28 | return 29 | } 30 | dump, _ := httputil.DumpResponse(res, true) 31 | logger.Print("<<< Response from ", res.Request.URL.String(), "\n", sanitize(string(dump)), "\n") 32 | } 33 | 34 | func sanitize(dump string) string { 35 | dump = sanitizeMultipartFile(dump) 36 | return dump 37 | } 38 | 39 | var reMultipartBinary = regexp.MustCompile(`Content-Type: application/octet-stream\r\n\r\n([\s\S]*?)\r\n--`) 40 | 41 | func sanitizeMultipartFile(dump string) string { 42 | matches := reMultipartBinary.FindStringSubmatch(dump) 43 | if len(matches) == 2 { 44 | replace := fmt.Sprintf("[ %d bytes of binary data... ]", len(matches[1])) 45 | dump = strings.Replace(dump, matches[1], replace, 1) 46 | } 47 | return dump 48 | } 49 | -------------------------------------------------------------------------------- /utils/log-http_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "testing" 4 | 5 | func Test_sanitizeMultipartFile(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | dump string 9 | want string 10 | }{ 11 | {name: "no multipart", dump: noMultipartDump, want: noMultipartWant}, 12 | {name: "multipart binary", dump: multipartBinaryDump, want: multipartBinaryWant}, 13 | } 14 | for _, tt := range tests { 15 | t.Run(tt.name, func(t *testing.T) { 16 | if got := sanitizeMultipartFile(tt.dump); got != tt.want { 17 | t.Errorf("sanitizeMultipartFile() = %v, want %v", got, tt.want) 18 | } 19 | }) 20 | } 21 | } 22 | 23 | var ( 24 | noMultipartDump = "POST / HTTP/1.1\r\nHost: localhost:8080\r\nContent-Type: application/json\r\n\r\n{\"foo\":\"bar\"}" 25 | noMultipartWant = noMultipartDump 26 | 27 | multipartBinaryDump = "POST / HTTP/1.1\r\nHost: localhost:8080\r\nContent-Type: multipart/form-data; boundary=--------------------------1234567890\r\n\r\n----------------------------1234567890\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\nbar\r\n----------------------------1234567890\r\nContent-Disposition: form-data; name=\"file\"; filename=\"file.txt\"\r\nContent-Type: application/octet-stream\r\n\r\nThis is file content\r\n----------------------------1234567890--\r\nContent-Disposition: form-data; name=\"baz\"\r\n\r\nqux\r\n----------------------------1234567890--\r\n" 28 | multipartBinaryWant = "POST / HTTP/1.1\r\nHost: localhost:8080\r\nContent-Type: multipart/form-data; boundary=--------------------------1234567890\r\n\r\n----------------------------1234567890\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\nbar\r\n----------------------------1234567890\r\nContent-Disposition: form-data; name=\"file\"; filename=\"file.txt\"\r\nContent-Type: application/octet-stream\r\n\r\n[ 20 bytes of binary data... ]\r\n----------------------------1234567890--\r\nContent-Disposition: form-data; name=\"baz\"\r\n\r\nqux\r\n----------------------------1234567890--\r\n" 29 | ) 30 | -------------------------------------------------------------------------------- /utils/pretty.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "net/url" 6 | ) 7 | 8 | // PrettyJSON возвращает переданную структуру в виде форматированной JSON-строки с отступами. 9 | func PrettyJSON(data any) string { 10 | indented, err := json.MarshalIndent(data, "", " ") 11 | if err != nil { 12 | return "" 13 | } 14 | 15 | return string(indented) 16 | } 17 | 18 | // PrettyQuery возвращает параметры запроса в виде форматированной строки. 19 | func PrettyQuery(query url.Values) string { 20 | s := "" 21 | for key := range query { 22 | s += key + "=" + query.Get(key) + "\n" 23 | } 24 | return s 25 | } 26 | --------------------------------------------------------------------------------