Как определить права на манипуляции с ресурсами для разных пользователей REST-приложения в спецификации OpenAPI: пример аутентификации с JWT-токеном для микросервисной системы интернет-магазина.
Проектирование REST API
Продолжим проектирование микросервисной системы интернет-магазина из прошлой статьи и составим спецификацию OpenAPI для REST-приложения, которое принимает запросы с клиента и направляет их к разным микросервисам, реализуя паттерн API Gateway. Напомню, проектируемая система предназначена обслуживания запросов следующих пользователей:
- Покупатель может просматривать товары, добавлять их в корзину и покупать товары, делая заказы. Также покупателю доступны функции управления своими заказами (создать, изменить, отменить) и манипулирования составом корзины (добавить товар, удалить товар, изменить количество товара).
- Менеджер интернет-магазина отвечает за управление товарами и поставщиками (создать, изменить, удалить). Также ему доступен просмотр активных заказов и просмотр отчетов по выполненным заказам согласно заданным параметрам фильтрации.
- Оператор склада изменяет состояние заказа, поскольку управляет упаковкой товаров в заказы.
При проектировании REST API важно помнить принцип работы этого архитектурного стиля построения выб-приложения. По сути, методы HTTP-запросов, которые направляет клиент на сервер для доступа к ресурсу можно представить в виде штекеров (коннекторов) для соединения с определенными розетками. Как я уже отмечала здесь,
Маршрут (Route) — это URL-адрес, который направляет запрос к определенным конечным точкам с помощью HTTP-методов. Маршрут может иметь несколько конечных точек. Конечная точка (Endpoint) — это непосредственно обращение к маршруту конкретным HTTP-методом, чтобы выполнить определенную задачу и вернуть данные с сервера клиенту.
А за розеткой, т.е. маршрутом, скрывается класс, таблица БД или другое материализованное представление для работы с сущностью домена, т.е. операций с данными. В коде такое сопоставление маршрута с функцией обработки данных выполняется, как правило, средствами специализированных фреймворков. Например, для Python есть фреймворк Flask API, пример использования которого я описывала здесь. Flask API предоставляет декорированный метод @route() для реализации конечных точек, позволяя обратиться к маршруту указанными HTTP-методами, чтобы выполнять необходимые манипуляции с данными.
Возвращаясь к рассматриваемому кейсу интернет-магазина, составим перечень сущностей домена, которые будут скрываться за маршрутами веб-приложения.
Ресурс (Сущность) | Поле | Смысл поля | Тип данных |
Product (Товар) | id | Идентификатор товара | integer |
name | Название товара | string | |
category | Категория товара | string | |
provider | Поставщик товара | object | |
price | Стоимость товара | number | |
quantity | Количество единиц товара | integer | |
Provider (Поставщик) | id | Идентификатор поставщика | integer |
name | Название поставщика | string | |
INN | ИНН поставщика | string | |
site | Сайт поставщика | string | |
phone | Телефон поставщика | string | |
address | Адрес поставщика | string | |
Order (Заказ) | id | Идентификатор заказа | integer |
start_date | Дата и время создания заказа | string, т.к. в JSON нет типа данных для даты и времени. Указать, что это дата можно через format data | |
state | Состояние заказа (новый, принят, найден, подтвержден, собран, оплачен, отменен) | string | |
items | Массив товаров, добавленных в заказ, с указанием количества каждого товара | object | |
sum | Сумма заказа | number | |
Item (Товар в корзине) | id | Идентификатор товара в корзине | |
item | Товар, добавленный в корзину | object | |
quantity | Количество единиц товара | integer |
Далее составим перечень вариантов использования (ВИ), сопоставив их с маршрутами и конечными точками, т.е. методами HTTP-запросов, которые будут отправляться к этим URL-адресам. Также сразу отметим необходимость аутентификации для доступа к тем или иным данным посредством HTTP-запросов.
Актор | Use Case (ВИ) | Маршрут | Конечная точка (HTTP-метод) | Аутентификация |
· Менеджер · Покупатель | UC-1. Посмотреть каталог товаров | /product | GET | Не нужна |
UC-2. Найти товар | /product | GET с параметрами фильтрации | Не нужна | |
Менеджер | UC-3. Добавить товар | /product | POST | |
· Менеджер · Покупатель | UC-4. Посмотреть товар | /product/{id} | GET | Не нужна |
Менеджер | UC-6. Изменить товар | /product/{id} | PUT | Нужна |
Менеджер | UC-5. Удалить товар | /product/{id} | DELETE | Нужна |
· Менеджер · Покупатель | UC-7. Посмотреть список поставщиков | /provider | GET | Не нужна |
UC-8. Найти поставщика | /provider | GET с параметрами фильтрации | Не нужна | |
Менеджер | UC-10. Добавить поставщика | /provider | POST | Нужна |
· Менеджер · Покупатель | UC-9. Посмотреть поставщика | /provider/{id} | GET | Нужна |
Менеджер | UC-11. Изменить поставщика | /provider/{id} | PUT | Нужна |
Менеджер | UC-12. Удалить поставщика | /provider/{id} | DELETE | Нужна |
Менеджер | UC-12. Получить аналитический отчет по выполненным заказам | /analytics | POST (в теле запроса параметры фильтрации данных для генерации отчета) | Нужна |
Менеджер | UC-13. Посмотреть все заказы | /order | GET | Нужна |
· Покупатель · Менеджер | UC-14. Посмотреть все свои заказы | /order | GET с параметрами фильтрации | Нужна |
· Менеджер · Покупатель · Оператор склада | UC-15. Найти заказ | /order | GET с параметрами фильтрации | Нужна |
Покупатель | UC-16. Посмотреть товары в корзине | /item | GET | Нужна |
Покупатель | UC-17. Добавить товар в корзину | /item | POST | Нужна |
Покупатель | UC-18. Изменить количество товара в корзине | /item/{id} | PUT со значением | Нужна |
Покупатель | UC-19. Удалить товар из корзины | /item/{id} | DELETE | Нужна |
Покупатель | UC-20. Создать заказ | /order | POST | Нужна |
Покупатель | UC-21. Подтвердить заказ | /order/{id} | PATCH | Нужна |
Покупатель | UC-22. Оплатить заказ | /order/{id} | PATCH | Нужна |
· Менеджер · Покупатель | UC-23. Отменить заказ | /order/{id} | PATCH | Нужна |
Оператор склада | UC-24. Собрать заказ | /order/{id} | PATCH | Нужна |
Для аутентификации пользователя будем использовать довольно простой метод — токен на предъявителя (Bearer token) – веб-маркер JSON (JWT, JSON Web Token), который представляет собой текстовую строку, включенную в заголовок запроса. Использование токена не требует от предъявителя доказательства владения. Имея токен, можно получить доступ к ресурсам. Токен можно отозвать, и обычно он выдается на ограниченный период времени, чтобы снизить риск несанкционированного доступа к данным. Про этот и другие методы аутентификации в веб-приложениях я упоминала здесь.
В данном примере механизм JWT выбран из-за простоты, поскольку сообщение с JWT-токеном может быть реализовано любым языком программирования, поддерживающим криптографическое шифрование данных, например, алгоритмом HMAC SHA256 или RSA, сведения о котором содержатся в разделе заголовка токена, отделенного точкой от полезной нагрузки и подписи. Полезная нагрузка включает информацию о пользователе, а подпись подтверждает отправителя и гарантирует, что сообщение не было изменено во время передачи. JWT-токен с ограниченным временем жизни дает возможность регулировать длительность клиентской сессии и позволяет stateless-системе вести себя как stateful, когда ответ сервера зависит от состояния клиента. Обратной стороной достоинств JWT-токенов является их уязвимость из-за передачи в открытом виде: токен может быть расшифрован и даже изменен злоумышленником с помощью специализированных средств. Впрочем, вопросы информационной безопасности настолько глубокие и серьезные, что требуют проработки в отдельном материале, что я сделаю когда-нибудь в другой раз.
Закончив с предварительным проектированием REST API, можно перейти к детальному: разработке спецификации OpenAPI, упрощенный пример которой рассмотрен в прошлой статье.
Основы архитектуры и интеграции информационных систем
Код курса
OAIS
Ближайшая дата курса
20 января, 2025
Продолжительность
16 ак.часов
Стоимость обучения
36 000 руб.
Разработка спецификации OpenAPI с secure-схемой
Как обычно, формировать спецификацию будем с помощью редактора Swagger в веб-сервисе SwaggerHub, который доступен для бесплатного использования, но требует предварительной регистрации. Выбрав простой шаблон (Simple API), далее начнем разрабатывать саму спецификацию в формате YAML, внося своих акторов, маршруты и конечные точки, а также структуры данных.
Поскольку спецификация OpenAPI поддерживает вложенные структуры данных и возможность их переиспользования за счет механизма ссылок, разделим сущность домена Товар на Product, которым манипулирует менеджер, указывая количество на складе (свойство quantity) и SKU – товарную единицу номенклатуры. В спецификации OpenAPI для этого будут описаны соответствующие схемы данных. Аналогично зададим возможные значения для состояния товара, которые могут изменить разные пользователи. Например, заказ может быть отменен или самим клиентом, или менеджером магазина, если он не подтвержден / не оплачен в течении 3-х дней. Тогда схема данных для изменения состояния заказа в формате YAML будет выглядеть так:
CanceledOrder: required: - state properties: state: type: string enum: - отменен клиентом - отменен менеджером example: отменен
Кроме того, в разделе схемы описана схема безопасности:
securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT
Ссылка на эту схему указана в HTTP-методах обращения к ресурсам с помощью ключевого слова security:
security: - bearerAuth: []
Например, добавить новый товар в каталог может только менеджер, что описывает следующий участок спецификации OpenAPI:
paths: /product: post: tags: - manager summary: 'Добавить товар' operationId: addProduct security: - bearerAuth: [] description: Добавить новый товар в каталог responses: '201': description: товар добавлен content: application/json: schema: type: object properties: id: type: integer example: 1 product: $ref: '#/components/schemas/Product' '400': description: некорректный ввод '409': description: такой товар уже есть requestBody: content: application/json: schema: $ref: '#/components/schemas/Product' description: Новый товар для добавления в каталог
Указание схемы безопасности означает, что для доступа к ресурсу необходимо предоставить аутентификационный токен (bearer token). Как и базовая аутентификация с прямым указанием логина и пароля, аутентификацию с bearer token следует использовать только через HTTPS (SSL). Описав эту схему безопасности в разделе components/securitySchemes, можно использовать ключевое слово security, чтобы применить эту схему к желаемой области: глобальной, т.е. ко всем конечным точкам или или конкретным операциям, как в рассмотренном примере.
Токен аутентификации передается в заголовке или в параметре запроса. Поскольку в квадратных скобках bearerAuth: [] пусто, области безопасности, т.е. конкретные требования или ограничения токена не указаны. Указав в скобках дополнительные параметры, можно регулировать разрешения на манипуляции с данными, ограничения IP-адреса и т.д. Однако, в спецификации OpenAPI области используются только с OAuth 2 и OpenID Connect, применяемыми для аутентификации через стороннего провайдера, например, ЕСИА, UML-диаграмму последовательности для которой я разбирала здесь.
Поскольку за аутентификацию и авторизацию пользователей в проектируемой системе отвечает отдельный микросервис, описываемый REST API должен давать возможность каждому пользователю ввести свои учетные данные для входа и выдать JWT-токен зарегистрированным пользователям. Для этого я добавила маршрут /login, к которому можно обратиться любой пользователь (user) путем отправки HTTP-запроса POST с передачей в теле запроса учетных данных (логина и пароля). В спецификации OpenAPI это описывает следующий участок кода:
paths: /login: post: tags: - user summary: Вход пользователя в систему description: Аутентификация пользователя с использованием Bearer Token (токен на предъявителя) requestBody: required: true content: application/json: schema: type: object properties: username: type: string description: Логин пользователя example: anna_vichugova password: type: string format: password description: Пароль пользователя responses: '200': description: Успешная аутентификация content: application/json: schema: type: object properties: token: type: string description: Bearer Token для дальнейшей аутентификации example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 '401': description: Ошибка аутентификации, неверные учетные данные '500': description: Внутренняя ошибка сервера
Основы архитектуры и интеграции информационных систем
Код курса
OAIS
Ближайшая дата курса
20 января, 2025
Продолжительность
16 ак.часов
Стоимость обучения
36 000 руб.
Таким образом, итоговая спецификация для рассматриваемого шлюза REST API интернет-магазина выглядит следующим образом:
openapi: 3.0.0 servers: # Added by API Auto Mocking Plugin - description: SwaggerHub API Auto Mocking url: https://virtserver.swaggerhub.com/VICHIGOVAANNA/Internet-shop/2.0.0 info: description: 'Типичный интернет-магазин - демо-кейс Анны Вичуговой (расширенная версия для МСА, с JWT-аутентификацией)' version: 2.0.0 title: API интернет-магазина contact: email: anna@mail.com tags: - name: user description: Пользователь (все категории пользователей) - name: manager description: Менеджер - name: customer description: Покупатель - name: operator description: Оператор склада paths: /login: get: tags: - user description: Просмотр страницы ввода логина и пароля (входа в систему) summary: Просмотр страницы ввода логина и пароля (входа в систему) responses: '200': description: Успешное отображение страницы аутентификации content: application/json: schema: type: object properties: page: type: object description: Содержимое страницы для ввода логина и пароля '404': description: Страница не найдена '503': description: Сервер временно недоступен post: tags: - user summary: Вход пользователя в систему description: Аутентификация пользователя с использованием Bearer Token (токен на предъявителя) requestBody: required: true content: application/json: schema: type: object properties: username: type: string description: Логин пользователя example: anna@example.ru password: type: string format: password description: Пароль пользователя role: type: string enum: - manager - customer - operator description: Роль пользователя example: manager responses: '200': description: Успешная аутентификация content: application/json: schema: type: object properties: token: type: string description: Bearer Token для дальнейшей аутентификации example: eyJ0eXAwcvvcb1QiLCJhbGciOsdfsUzI1NiJ9.eyJpZCI6IjEzMzciLCJ1c2VybmFtZSI6ImJpem9uZSIswwwfcI6MTU5NDIwOTYwMCwicm9sZSI6InVzZXIifQ.ZvkYYnyM92xvxcvNW9_hSis7_x3_9rymsDcvcbvuOcc1I '401': description: Ошибка аутентификации, неверные учетные данные '500': description: Внутренняя ошибка сервера /analytics: post: tags: - manager summary: Получить аналитический отчет operationId: getAnalytics security: - bearerAuth: [] description: Генерация pdf-отчета по заданным фильтрам requestBody: required: true content: application/json: schema: type: object properties: client: type: string analysis_period: type: string format: date required: - client - analysis_period responses: '200': description: Отчет успешно сформирован content: application/pdf: schema: type: string format: binary '400': description: Некорректный запрос '500': description: Ошибка сервера /product: get: tags: - manager - customer - operator - user summary: Посмотреть каталог товаров operationId: viewProductCatalog description: Параметры фильтрации товаров в каталоге для поиска parameters: - in: query name: name description: название товара для поиска required: false schema: type: string - in: query name: category description: категория товара для поиска required: false schema: type: string - in: query name: provider description: поставщик товара для поиска required: false schema: type: string - in: query name: min_price description: минимальная цена товара для поиска schema: type: integer format: int32 minimum: 0 maximum: 100500 - in: query name: max_price description: максимальная цена товара для поиска schema: type: integer format: int32 minimum: 0 maximum: 10005000 responses: '200': description: результаты поиска по запросу content: application/json: schema: type: array items: $ref: '#/components/schemas/Product' '400': description: не верные параметры фильтрации post: tags: - manager - operator summary: 'Добавить товар' operationId: addProduct security: - bearerAuth: [] description: Добавить новый товар в каталог responses: '201': description: товар добавлен content: application/json: schema: type: object properties: id: type: integer example: 1 product: $ref: '#/components/schemas/Product' '400': description: некорректный ввод '409': description: такой товар уже есть requestBody: content: application/json: schema: $ref: '#/components/schemas/Product' description: Новый товар для добавления в каталог /provider: get: tags: - manager - customer - user summary: Посмотреть список поставщиков operationId: viewProviderCatalog description: Параметры фильтрации поставщиков в каталоге для поиска parameters: - in: query name: name description: название поставщика для поиска required: false schema: type: string - in: query name: INN description: ИНН поставщика для поиска required: false schema: type: string - in: query name: phone description: телефон поставщика для поиска required: false schema: type: string - in: query name: address description: адрес поставщика для поиска required: false schema: type: string responses: '200': description: результаты поиска по запросу content: application/json: schema: type: array items: $ref: '#/components/schemas/Provider' '400': description: не верные параметры фильтрации post: tags: - manager summary: Добавить поставщика operationId: addProvider security: - bearerAuth: [] description: Добавление нового поставщика в каталог responses: '201': description: поставщик добавлен content: application/json: schema: type: object properties: id: type: integer example: 1 product: $ref: '#/components/schemas/Provider' '400': description: некорректный ввод '409': description: такой поставщик уже есть requestBody: content: application/json: schema: $ref: '#/components/schemas/Provider' description: Новый поставщик для добавления в каталог /product/{id}: get: tags: - manager - customer - operator summary: Посмотреть товар operationId: viewProduct description: Просмотр данных о конкретном товаре по его ID parameters: - in: path name: id description: ID required: true schema: type: integer format: int64 example: 1 responses: '200': description: Товар найден content: application/json: schema: $ref: '#/components/schemas/Product' '404': description: 'Товар не найден' put: tags: - manager summary: Изменить товар operationId: updateProduct security: - bearerAuth: [] description: Изменение параметров товара parameters: - name: id in: path description: ID товара, параметры которого нужно изменить required: true schema: type: integer example: 1 responses: '200': description: параметры товара изменены успешно '400': description: некорректный ввод '404': description: товар не найден requestBody: content: application/json: schema: $ref: '#/components/schemas/Product' description: Измененные параметры товара delete: tags: - manager summary: 'Удалить товар' operationId: deleteProduct security: - bearerAuth: [] description: Удаление товара по его идентификатору parameters: - name: id in: path description: Идентификатор товара для удаления required: true schema: type: integer example: 1 responses: '200': description: товар успешно удален '404': description: товар не найден '500': description: внутренняя ошибка сервера /provider/{id}: get: tags: - manager - customer summary: Посмотреть поставщика operationId: viewProvider security: - bearerAuth: [] description: Просмотр информации о конкретном поставщике по его ID parameters: - in: path name: id description: Идентификатор поставщика required: true schema: type: integer format: int64 example: 1 responses: '200': description: Поставщик найден content: application/json: schema: $ref: '#/components/schemas/Provider' '404': description: Поставщик не найден put: tags: - manager summary: Изменить поставщика operationId: updateProvider security: - bearerAuth: [] description: Изменение поставщика по его идентификатору parameters: - name: id in: path description: ID поставщика, параметры которого нужно изменить required: true schema: type: integer example: 1 responses: '200': description: параметры поставщика изменены успешно '400': description: некорректный ввод '404': description: поставщик не найден requestBody: content: application/json: schema: $ref: '#/components/schemas/Provider' description: Данные поставщика delete: tags: - manager summary: Удалить поставщика operationId: deleteProvider security: - bearerAuth: [] description: Удаление поставщика по его идентификатору parameters: - name: id in: path description: Идентификатор поставщика для удаления required: true schema: type: integer example: 1 responses: '200': description: поставщик успешно удален '404': description: поставщик не найден '500': description: внутренняя ошибка сервера /order: get: tags: - manager - customer - operator summary: Посмотреть заказы operationId: viewOrders security: - bearerAuth: [] description: Параметры фильтрации заказов для поиска parameters: - in: query name: id description: номер заказа для поиска required: false schema: type: integer - in: query name: start_date description: дата создания заказа required: false schema: type: string format: date responses: '200': description: результаты поиска по запросу content: application/json: schema: type: array items: $ref: '#/components/schemas/Order' '400': description: не верные параметры фильтрации /order/{id}: patch: tags: - customer - manager - operator summary: Изменить состояние заказа security: - bearerAuth: [] operationId: changeOrderState description: Подтвердить, отменить или изменить статус заказа parameters: - name: id in: path description: ID заказа, статус которого надо изменить required: true schema: type: integer example: 1 responses: '200': description: Заказ успешно изменен content: application/json: schema: type: array items: $ref: '#/components/schemas/Order' '400': description: Некорректный ввод '404': description: Заказ не найден requestBody: content: application/json: schema: oneOf: - $ref: '#/components/schemas/approvedOrder' - $ref: '#/components/schemas/CanceledOrder' - $ref: '#/components/schemas/CollectedOrder' - $ref: '#/components/schemas/HoldedOrder' discriminator: propertyName: operationType description: Новый статус заказа /item: get: tags: - customer summary: Посмотреть товары в корзине operationId: viewItem security: - bearerAuth: [] description: Товары в корзине responses: '200': description: товары в корзине content: application/json: schema: type: array items: $ref: '#/components/schemas/Item' '500': description: внутренняя ошибка сервера post: tags: - customer summary: Добавить товар в корзину operationId: addItem security: - bearerAuth: [] responses: '201': description: товар добавлен content: application/json: schema: type: object properties: id: type: integer example: 1 product: $ref: '#/components/schemas/Item' '400': description: некорректный ввод requestBody: content: application/json: schema: $ref: '#/components/schemas/Item' description: Товар для добавления в корзину /item/{id}: patch: tags: - customer summary: Изменить количество товара в корзине #operationId: updateItem security: - bearerAuth: [] description: Изменение количества товара по его идентификатору parameters: - name: id in: path description: ID товара, количество которого нужно изменить в корзине required: true schema: type: integer example: 1 requestBody: content: application/json: schema: type: integer minimum: 1 maximum: 100500 description: Новое количество единиц товара в корзине responses: '200': description: 'Количество товара успешно изменено' content: application/json: schema: type: object properties: item: $ref: '#/components/schemas/SKU' quantity: type: integer description: Количество единиц товара в корзине example: 56 '400': description: Некорректный ввод '404': description: Товар не найден в корзине delete: tags: - customer summary: Удалить товар из корзины operationId: deleteItem security: - bearerAuth: [] description: Удаление товара из корзины по его идентификатору parameters: - name: id in: path description: Идентификатор товара для удаления required: true schema: type: integer example: 1 responses: '200': description: Товар успешно удален из корзины '404': description: Товар не найден '500': description: Внутренняя ошибка сервера components: schemas: SKU: type: object required: - name - category - provider - price properties: name: type: string example: 'яблоки' category: type: string example: 'еда' provider: $ref: '#/components/schemas/Provider' price: type: number minimum: 0.001 maximum: 100500 example: 145.00 Product: type: object required: - sku - quantity properties: sku: $ref: '#/components/schemas/SKU' quantity: type: integer minimum: 1 maximum: 100500 example: 10 Provider: required: - name - INN - phone - address properties: id: type: integer example: 1 name: type: string example: ООО Ромашка INN: type: string example: 1234567890 site: type: string format: url example: https://www.ooo-camomile.com phone: type: string example: 7-495-123-45-67 address: type: string example: г. Москва, ул. Ленина, 123 Order: required: - id - start_date - state - items - sum properties: id: type: integer example: 1 start_date: type: string format: date example: 2023-09-30 items: type: array items: $ref: '#/components/schemas/Item' state: type: string enum: - новый - принят - найден - подтвержден - собран - оплачен - отменен example: принят sum: type: number example: 145.00 Item: required: - id - item - quantity properties: id: type: integer example: 1 item: $ref: '#/components/schemas/SKU' quantity: type: integer minimum: 1 maximum: 100500 example: 45 approvedOrder: required: - state properties: state: type: string enum: - подтвержден - не подтвержден example: подтвержден CanceledOrder: required: - state properties: state: type: string enum: - отменен клиентом - отменен менеджером example: отменен CollectedOrder: required: - state properties: state: type: string enum: - собран - на сборке example: собран HoldedOrder: required: - state properties: state: type: string enum: - на оплате - отказ от оплаты example: на оплате securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT
Помимо ранее показанных маршрутов и конечных точек с HTTP-методами со структурами данных и примерами их наполнения, в этой спецификации также показаны HTTP-ответы на запросы, подробнее о которых я писала здесь. Редактор Swagger сразу отображает UI для тестирования REST API. Также разработанная спецификация доступна по ссылке, поскольку при создании проекта для него была задана публичная видимость. Исходный код можно взять в моем Github-репозитории.
Надеюсь, что это небольшое руководство поможет начинающим аналитикам лучше понять основы проектирования REST API и принципов его документирования с помощью спецификаций OpenAPI. Про то, какие ошибки чаще всего встречаются при разработке спецификации OpenAPI и как их избежать, вы узнаете в этом материале.
Основы архитектуры и интеграции информационных систем
Код курса
OAIS
Ближайшая дата курса
20 января, 2025
Продолжительность
16 ак.часов
Стоимость обучения
36 000 руб.
А подробнее познакомиться со всеми рассмотренными темами, а также другими основами архитектуры и интеграции информационных систем вы сможете на моих курсах в Школе прикладного бизнес-анализа на базе нашего лицензированного учебного центра обучения и повышения квалификации системных и бизнес-аналитиков в Москве: