GraphQL — альтернативный подход к API

Урок 4 из 8

Урок 07.04: GraphQL — альтернативный подход к API

Цель урока

Разобраться, как устроен GraphQL — альтернатива REST для проектирования API: язык описания схемы (SDL), запросы (Query), мутации (Mutation), подписки (Subscription), а также типичные проблемы (over-/under-fetching, N+1) и подходы к версионированию, ошибкам и ограничению нагрузки. Особый фокус: сравнение с REST/SOAP/gRPC и вопросы, которые чаще всего задают системным аналитикам на собеседованиях.


Ключевые понятия

Термин Определение
GraphQL Язык запросов к API и runtime для его исполнения: клиент описывает, какие данные ему нужны, сервер возвращает ровно их
Schema (Схема) Контракт API — описание всех типов, запросов, мутаций и подписок на языке SDL
SDL (Schema Definition Language) Синтаксис описания GraphQL-схемы (type, enum, input, interface, union)
Query Корневой тип для операций чтения данных
Mutation Корневой тип для операций изменения данных (создание/обновление/удаление)
Subscription Корневой тип для подписки на события в реальном времени (через WebSocket)
Resolver Функция на сервере, которая возвращает значение для конкретного поля схемы
Over-fetching Клиент получает больше полей, чем ему нужно
Under-fetching Клиенту не хватает данных за один запрос, нужно делать дополнительные запросы
N+1 problem Проблема производительности: 1 запрос к списку + N запросов к связанным сущностям по отдельности
Introspection Способность GraphQL-сервера описать свою же схему по запросу — основа для автогенерации документации и автокомплита

1. Зачем появился GraphQL: проблема REST

1.1. Аналогия: меню «комплексный обед» vs «конструктор блюда»

REST API — это меню из готовых блюд («комплексных обедов»): GET /tasks/42 всегда возвращает фиксированный набор полей, который определил разработчик сервера. Если вам нужно только название блюда, а салат и суп вам не нужны — придётся либо съесть всё («over-fetching»), либо доплачивать и идти за салатом в другое заведение («under-fetching» — отдельный запрос).

GraphQL — это «конструктор блюда»: клиент сам перечисляет, что ему нужно из доступного набора, и получает ровно это, за один «заказ» (один HTTP-запрос).

1.2. Over-fetching и Under-fetching на примере Task Manager

Продолжим пример Task Manager из предыдущих уроков. REST-эндпоинт возвращает полный объект задачи:

GET /api/v1/tasks/42
{
    "id": 42,
    "title": "Подготовить отчёт",
    "description": "Очень длинный текст описания на 2000 символов...",
    "status": "in_progress",
    "priority": "high",
    "created_at": "2026-06-01T10:00:00Z",
    "updated_at": "2026-06-10T14:30:00Z",
    "assignee_id": 7,
    "project_id": 3,
    "tags": ["backend", "Q2"],
    "attachments": [ ... ]
}

Сценарий: мобильное приложение показывает список задач, и на экране нужны только title, status и имя исполнителя (а не assignee_id).

  • Over-fetching: REST вернёт все 10+ полей, включая description на 2000 символов — лишний трафик, особенно заметно на мобильной сети.
  • Under-fetching: чтобы получить имя исполнителя, нужен второй запрос GET /users/7 — для списка из 50 задач это потенциально 50 дополнительных запросов (см. N+1, раздел 6.2).

GraphQL-запрос для того же экрана — один запрос, ровно нужные поля:

query {
  tasks(status: IN_PROGRESS) {
    title
    status
    assignee {
      name
    }
  }
}
{
  "data": {
    "tasks": [
      { "title": "Подготовить отчёт", "status": "IN_PROGRESS", "assignee": { "name": "Иван Петров" } }
    ]
  }
}

Для аналитика: если в требованиях написано «мобильное приложение должно работать с минимальным трафиком и поддерживать гибкие, часто меняющиеся экраны без переписывания backend» — это аргумент в пользу GraphQL. Если «контракт должен быть стабильным, явным и легко кэшируемым на уровне HTTP» — это аргумент в пользу REST (подробнее — раздел 10).


2. Схема (SDL) — контракт GraphQL API

2.1. Типы данных

Тип Назначение Пример
Scalar (встроенные) Int, Float, String, Boolean, ID title: String
Object type Составной тип с полями type Task { ... }
Enum Перечисление допустимых значений enum TaskStatus { TODO IN_PROGRESS DONE }
Input type Тип для входных параметров мутаций (нельзя смешивать с Object type) input CreateTaskInput { ... }
Interface / Union Полиморфизм — аналог oneOf/allOf из OpenAPI (см. Урок 07.02) union SearchResult = Task | User

Восклицательный знак ! означает non-nullable — поле не может быть null. title: String! — title всегда есть; description: String — может быть null.

2.2. Пример схемы Task Manager

type Task {
  id: ID!
  title: String!
  description: String
  status: TaskStatus!
  priority: Priority!
  assignee: User
  comments: [Comment!]!
  createdAt: String!
}

enum TaskStatus {
  TODO
  IN_PROGRESS
  DONE
}

enum Priority {
  LOW
  MEDIUM
  HIGH
}

type User {
  id: ID!
  name: String!
  email: String!
}

type Comment {
  id: ID!
  text: String!
  author: User!
  createdAt: String!
}

input CreateTaskInput {
  title: String!
  description: String
  priority: Priority!
  assigneeId: ID
}

type Query {
  task(id: ID!): Task
  tasks(status: TaskStatus, assigneeId: ID, limit: Int = 20, offset: Int = 0): [Task!]!
}

type Mutation {
  createTask(input: CreateTaskInput!): Task!
  updateTaskStatus(id: ID!, status: TaskStatus!): Task!
  addComment(taskId: ID!, text: String!): Comment!
}

type Subscription {
  taskUpdated(id: ID!): Task!
}

Это та же модель Task/Comment/User, которая в REST-варианте описывалась как набор эндпоинтов /tasks, /tasks/{id}/comments, /users/{id} (см. задание 2 Урока 07.01) — GraphQL-схема — это один файл, описывающий все возможности API.

2.3. Introspection — самодокументируемость

GraphQL-сервер умеет отвечать на специальный запрос { __schema { types { name fields { name } } } } — описать сам себя. На этом строятся инструменты вроде GraphiQL и Apollo Studio: автокомплит, автогенерация документации и валидация запросов «из коробки», без отдельного шага «выгрузить и опубликовать спецификацию», как с OpenAPI.


3. Запросы (Query)

3.1. Базовый запрос

query {
  task(id: "42") {
    title
    status
    assignee {
      name
      email
    }
  }
}

Структура запроса повторяет форму ответа — это одна из причин, почему GraphQL легко читать.

3.2. Переменные (Variables)

Жёстко закодированные значения (id: "42") — плохая практика. Переменные передаются отдельно от текста запроса (аналог параметризованных SQL-запросов — защита от инъекций «из коробки»):

query GetTask($taskId: ID!) {
  task(id: $taskId) {
    title
    status
  }
}
// Variables
{ "taskId": "42" }

3.3. Алиасы и фрагменты

Алиас — переименование поля в ответе (полезно при запросе одного и того же поля с разными аргументами):

query {
  highPriority: tasks(status: TODO, priority: HIGH) { title }
  lowPriority: tasks(status: TODO, priority: LOW) { title }
}

Фрагмент — повторно используемый набор полей (аналог переиспользуемой схемы $ref в OpenAPI):

fragment TaskBrief on Task {
  id
  title
  status
}

query {
  tasks(status: IN_PROGRESS) { ...TaskBrief }
}

4. Мутации (Mutation)

Создание, обновление и удаление данных — через корневой тип Mutation. По конвенции мутация возвращает изменённый объект, чтобы клиент сразу обновил локальный кэш без дополнительного запроса:

mutation CreateTask($input: CreateTaskInput!) {
  createTask(input: $input) {
    id
    title
    status
    createdAt
  }
}
// Variables
{
  "input": { "title": "Подготовить отчёт", "priority": "HIGH", "assigneeId": "7" }
}

Для аналитика: в отличие от REST, где POST /tasks (создание) и PATCH /tasks/{id} (изменение статуса) — это два разных эндпоинта с разной семантикой HTTP-методов (см. Урок 07.01), в GraphQL это две разные мутации (createTask, updateTaskStatus) в одном Mutation-типе — вся семантика именования и побочных эффектов лежит на названии мутации, а не на HTTP-методе (физически почти все GraphQL-запросы — это POST /graphql).


5. Подписки (Subscription) — реальное время

subscription {
  taskUpdated(id: "42") {
    id
    status
    updatedAt
  }
}

Subscription держит открытое WebSocket-соединение — клиент получает событие сразу, когда сервер публикует его (через pubsub.publish(...) в резолвере).

Сравнение с другими подходами к «событиям», разобранными в модуле:

GraphQL Subscription Webhook (Урок 07.07) Брокер сообщений (Урок 07.06)
Кто инициирует Клиент открывает WS-соединение Источник делает HTTP-запрос на URL клиента Producer пишет в топик/очередь, consumer читает
Нужен публичный endpoint у клиента? Нет Да Нет
Типичный потребитель Фронтенд (браузер/мобильное приложение) Backend-система партнёра Внутренний микросервис
Гарантии доставки Нет (если клиент offline — событие потеряно) At-least-once (с retry у источника) Настраиваемые (at-least-once, exactly-once с Kafka)

Для аналитика: Subscription — это «GraphQL-вариант» realtime для фронтенда (открытая вкладка браузера). Для интеграции между серверными системами, где нужна гарантия доставки и работа в фоне, — это webhook или брокер, а не GraphQL Subscription.


6. Resolvers и проблема N+1

6.1. Как сервер выполняет запрос

Каждое поле в схеме обслуживается функцией-резолвером. Для запроса:

query {
  tasks(status: IN_PROGRESS) {
    title
    assignee { name }
  }
}

Выполнение происходит послойно:

  1. Резолвер Query.tasks — один запрос к БД: «дать все задачи со статусом IN_PROGRESS» → получено 50 задач
  2. Для каждой из 50 задач вызывается резолвер Task.assignee

6.2. N+1 problem

Если резолвер Task.assignee наивно реализован как «сходить в БД за пользователем по assigneeId» — для 50 задач это 50 отдельных запросов к БД плюс 1 исходный запрос за списком задач = N+1 запросов.

Запрос 1:  SELECT * FROM tasks WHERE status = 'in_progress'      → 50 строк
Запрос 2:  SELECT * FROM users WHERE id = 7   (для задачи #1)
Запрос 3:  SELECT * FROM users WHERE id = 7   (для задачи #2, тот же assignee!)
Запрос 4:  SELECT * FROM users WHERE id = 12  (для задачи #3)
...
Запрос 51: SELECT * FROM users WHERE id = ... (для задачи #50)

Это классическая проблема производительности GraphQL — и один из самых частых вопросов на собеседовании.

6.3. Решение: DataLoader (batching + caching)

Вместо немедленного запроса к БД резолвер откладывает запрос и собирает все assigneeId за один «тик» event loop, затем делает один батч-запрос:

Вместо:  SELECT * FROM users WHERE id = 7
         SELECT * FROM users WHERE id = 7
         SELECT * FROM users WHERE id = 12
         ... (50 запросов)

DataLoader делает:
         SELECT * FROM users WHERE id IN (7, 12, 15, ...)   -- 1 запрос
         + кэширует результат на время одного GraphQL-запроса
         + раздаёт каждому резолверу его пользователя из батча

Для аналитика: если в нефункциональных требованиях к GraphQL API не упомянут DataLoader (или эквивалентный механизм батчинга) для всех «один-к-многим» связей — это риск деградации производительности при росте объёма данных, который проявится не на тестах (где данных мало), а в продакшене.


7. Обработка ошибок

В отличие от REST, где ошибка = HTTP-статус 4xx/5xx (см. Урок 07.01), GraphQL почти всегда возвращает 200 OK, даже если часть запроса завершилась с ошибкой:

{
  "data": {
    "task": {
      "title": "Подготовить отчёт",
      "assignee": null
    }
  },
  "errors": [
    {
      "message": "User with id 7 not found",
      "path": ["task", "assignee"],
      "extensions": { "code": "NOT_FOUND" }
    }
  ]
}

Это называется partial response — часть данных вернулась успешно (title), часть — нет (assignee: null + запись в errors).

Для аналитика: в требованиях к GraphQL API важно зафиксировать:

  • Используются ли коды ошибок в extensions.code (например, UNAUTHENTICATED, NOT_FOUND, VALIDATION_ERROR) — без этого клиент может различать ошибки только по тексту message, что хрупко
  • В каких случаях сервер всё же вернёт HTTP-статус не 200 (обычно — только при синтаксической ошибке самого запроса или при сбое транспорта, не бизнес-логики)

8. Версионирование: эволюция схемы вместо /v1/v2

В REST новая несовместимая версия API — это /api/v2/... (см. Урок 07.01, раздел 10). В GraphQL принятый подход — одна постоянно эволюционирующая схема:

  • Добавление поля — безопасно: старые клиенты, которые его не запрашивают, не замечают изменения
  • Удаление поля — небезопасно, поэтому сначала помечается директивой:
    type Task {
      assigneeId: ID @deprecated(reason: "Use 'assignee { id }' instead")
      assignee: User
    }
    
  • Поле остаётся доступным (но помечено как deprecated в introspection — IDE подсвечивают предупреждением), пока аналитика по логам не покажет, что старые клиенты перестали его запрашивать

Для аналитика: «версионирование» в GraphQL — это процесс управления жизненным циклом полей схемы (deprecation policy), а не выпуск параллельных версий эндпоинта. В ТЗ стоит фиксировать: как долго поле остаётся доступным после @deprecated, кто отслеживает использование устаревших полей.


9. Безопасность и ограничение нагрузки

9.1. Проблема: один запрос — произвольная вложенность

Клиент может отправить один запрос произвольной глубины:

query Evil {
  task(id: "42") {
    comments {
      author {
        tasks {
          comments {
            author {
              tasks { comments { author { tasks { id } } } }
            }
          }
        }
      }
    }
  }
}

Формально это один HTTP-запрос — Rate Limiting «N запросов в минуту» из Урока 07.01, раздел 12 не защищает от такого запроса: один такой запрос может стоить как тысячи обычных.

9.2. Защитные механизмы

Механизм Что делает
Depth limiting Запрос с вложенностью больше N (например, 7) отклоняется до выполнения
Query complexity / cost analysis Каждому полю присваивается «стоимость» (например, поле со списком — дороже скалярного); запрос со стоимостью выше лимита отклоняется
Лимит по стоимости вместо count Rate Limiting в GraphQL обычно считается в очках сложности за период, а не в «запросах в минуту»
Отключение Introspection в проде Часто отключают __schema в production, чтобы не раскрывать структуру API публично

Для аналитика: если REST-овский пункт «100 запросов в минуту» (раздел 12 Урока 07.01) механически перенести в ТЗ на GraphQL API — это не сработает. Для GraphQL в требованиях нужно указать максимальную глубину запроса и/или лимит сложности (query cost), а не только количество HTTP-запросов.


10. GraphQL vs REST vs SOAP vs gRPC

Критерий REST GraphQL SOAP gRPC
Формат данных JSON (обычно) JSON XML Protocol Buffers (binary)
Контракт OpenAPI (опционально) Схема (обязательна, встроена в протокол) WSDL (обязателен) .proto файл
Гибкость запроса полей Нет (фиксированный ответ на эндпоинт) Да — клиент выбирает поля Нет Нет (фиксированные сообщения)
HTTP-методы/статусы Используются по смыслу (GET/POST/PUT, 200/404/500) Почти всё — POST /graphql, статус почти всегда 200 POST, статус почти всегда 200, ошибка — в SOAP Fault Использует HTTP/2 streams, свои статус-коды
Кэширование на уровне HTTP Простое (по URL, через CDN/браузер) Сложное (один и тот же URL /graphql для всех запросов) Сложное Сложное
Загрузка файлов Нативно (multipart/form-data) Не из коробки, нужны расширения Через MTOM Через streaming
Типичный сценарий Публичные API, CRUD-сервисы Фронтенд с разнообразными экранами, агрегация данных из нескольких источников Enterprise/банки/1С (см. Урок 07.03) Внутренние высокопроизводительные межсервисные вызовы

Для аналитика: GraphQL особенно часто выбирают, когда один backend обслуживает несколько разных фронтендов (веб, мобильное приложение, партнёрский виджет) с разными требованиями к данным — паттерн BFF (Backend for Frontend, см. Урок 07.05) часто реализуется именно через GraphQL-гейтвей перед набором REST/gRPC-сервисов.


11. Инструменты

Инструмент Назначение
GraphiQL / GraphQL Playground Встроенная IDE для отправки запросов с автокомплитом на основе introspection — аналог Swagger UI для REST
Apollo Studio Платформа для управления GraphQL-схемой, мониторинга использования полей (включая @deprecated), composition нескольких схем (Federation)
Postman Поддерживает GraphQL-запросы как отдельный тип (автоматически подгружает схему через introspection) — см. Урок 07.08
Insomnia Альтернатива Postman с сильной поддержкой GraphQL

12. GraphQL: частые вопросы на собеседовании системного аналитика

Короткие ответы на вопросы, которые регулярно встречаются на собеседованиях — для быстрого повторения перед интервью.

1. Чем GraphQL принципиально отличается от REST? REST — это набор фиксированных эндпоинтов с заранее заданной формой ответа. GraphQL — один эндпоинт (/graphql), где клиент сам описывает запросом, какие поля и связи ему нужны.

2. Что такое over-fetching и under-fetching? Over-fetching — сервер возвращает больше данных, чем нужно клиенту. Under-fetching — клиенту не хватает данных за один запрос, нужны дополнительные запросы. GraphQL решает обе проблемы за счёт того, что форма ответа = форма запроса.

3. Какой HTTP статус-код возвращает GraphQL при ошибке в запросе данных? Обычно 200 OK, даже при ошибке — ошибка передаётся в массиве errors рядом с (частичными) data. Не-200 — только при сбое транспорта или синтаксической ошибке самого запроса.

4. Что такое resolver? Функция на сервере, которая возвращает значение конкретного поля схемы. Каждое поле в дереве запроса разрешается своим резолвером.

5. В чём суть проблемы N+1 и как её решают? Наивная реализация резолвера для связанных сущностей делает по одному запросу к БД на каждый элемент списка (N запросов) плюс 1 исходный — итого N+1. Решается батчингом запросов (паттерн DataLoader): все ID собираются и запрашиваются одним IN (...)-запросом.

6. Как версионируется GraphQL API? Отдельных версий (/v1, /v2) обычно нет — схема эволюционирует: новые поля добавляются, старые помечаются @deprecated и удаляются после того, как клиенты перестали их использовать.

7. Что такое mutation и чем она отличается от query? Query — операции чтения (без побочных эффектов), Mutation — операции изменения данных (создание/обновление/удаление). По конвенции мутация возвращает изменённый объект.

8. Как в GraphQL реализуется realtime? Через Subscription — клиент открывает WebSocket-соединение и получает события по подписке. Для серверных интеграций (без постоянного соединения) для realtime чаще используют webhooks или брокеры сообщений.

9. Какие риски безопасности специфичны для GraphQL? Произвольно глубокие/сложные запросы за один HTTP-вызов («один запрос — как тысяча обычных»). Защита — depth limiting и query complexity analysis, а не обычный rate limiting по количеству запросов.

10. Когда GraphQL — плохой выбор? Когда важны простое HTTP-кэширование, простые публичные API с предсказуемым набором эндпоинтов, передача файлов, или когда команда/инфраструктура не готовы поддерживать дополнительный слой (резолверы, DataLoader, мониторинг сложности запросов).


Вопросы для самопроверки

Базовый уровень

  1. Что такое схема (SDL) в GraphQL и чем она отличается от OpenAPI-спецификации по роли в проекте?
  2. Приведите пример over-fetching и under-fetching на примере любого REST API, который вы анализировали.
  3. Чем Query отличается от Mutation? Может ли Query изменять данные?
  4. Что означает ! после типа поля в SDL (например, title: String!)?
  5. Какой HTTP-метод и URL обычно используются для GraphQL-запросов?

Продвинутый уровень

  1. N+1: Дан запрос tasks { title assignee { name } }, возвращающий 200 задач от 15 разных исполнителей. Сколько запросов к БД сделает наивная реализация без DataLoader? Сколько — с DataLoader?
  2. Partial response: GraphQL вернул 200 OK с data.task.assignee = null и записью в errors с кодом NOT_FOUND. Что это значит для клиента, и как клиентскому приложению следует отрисовать такой ответ в UI?
  3. Версионирование: Поле Task.assigneeId помечено @deprecated(reason: "use assignee.id"). По логам видно, что 3 старых мобильных клиента продолжают его запрашивать. Можно ли удалить поле прямо сейчас? Что нужно сделать аналитику перед удалением?
  4. Безопасность: Почему классический Rate Limiting «100 запросов в минуту» (раздел 12 Урока 07.01) недостаточен для защиты GraphQL API? Какие два механизма нужно добавить?
  5. Выбор технологии: Команда строит единый backend для веб-приложения, мобильного приложения и партнёрского виджета — у каждого свой набор нужных полей и они часто меняются. Внутри backend — 4 микросервиса на gRPC. Предложите архитектуру с использованием GraphQL и обоснуйте роль каждой технологии.

Практическое задание

Задание 1. Перевод REST в GraphQL-схему (2 балла)

В Уроке 07.01 (Задание 2) вы проектировали REST-эндпоинты для Task Manager (CRUD для Task и Comment, смена статуса, фильтрация списка).

  1. (1,5 балла) Опишите на SDL типы Task, Comment, User, корневые типы Query и Mutation, покрывающие те же возможности (минимум: получение задачи по id, список задач с фильтрами, создание задачи, смена статуса, добавление комментария).
  2. (0,5 балла) Какие из REST-эндпоинтов из Урока 07.01 не имеют прямого аналога в виде отдельного "эндпоинта" в GraphQL-варианте и почему?

Задание 2. Over-/Under-fetching (2 балла)

Ситуация: дизайнер прислал макет экрана «Доска задач», где для каждой карточки нужны: title, status, priority, имя исполнителя и количество комментариев (не сами комментарии).

  1. (0,5 балла) Опишите, сколько REST-запросов потребуется для отрисовки доски из 30 задач при «классическом» REST API из Урока 07.01 (без специального batch-эндпоинта).
  2. (1 балл) Напишите GraphQL-запрос, который вернёт все нужные данные для доски за один запрос. (Подсказка: для количества комментариев в схему нужно будет добавить поле, агрегирующее comments.)
  3. (0,5 балла) Назовите одно преимущество и один риск подхода "один универсальный GraphQL-запрос для экрана" по сравнению с несколькими простыми REST-эндпоинтами.

Задание 3. N+1 problem (2 балла)

Резолвер Task.assignee реализован так: при каждом вызове делает SELECT * FROM users WHERE id = $assigneeId.

  1. (0,5 балла) Дан запрос, возвращающий 100 задач. Сколько всего SQL-запросов выполнится при текущей реализации (включая запрос за списком задач)?
  2. (1 балл) Опишите, как DataLoader изменит этот сценарий: какие запросы будут выполнены и в каком порядке.
  3. (0,5 балла) Почему эта проблема может остаться незамеченной на этапе тестирования (с 3 тестовыми задачами), но проявится в продакшене?

Задание 4. Ошибки и partial response (2 балла)

Клиент отправил запрос:

query {
  task(id: "999") {
    title
    assignee { name }
  }
}

Задачи с id = "999" не существует.

  1. (0,5 балла) Какой HTTP-статус вернёт сервер?
  2. (1 балл) Опишите содержимое полей data и errors в ответе.
  3. (0,5 балла) Спроектируйте код ошибки (extensions.code) для этого случая и опишите, как клиентское приложение должно на него реагировать в UI.

Задание 5. Чек-лист выбора технологии (2 балла)

Для каждого из сценариев выберите подход — REST, GraphQL, SOAP или gRPC — и обоснуйте в 1-2 предложениях:

Сценарий Подход Обоснование
1 Публичный API банка для сторонних разработчиков с жёсткими требованиями к аудиту запросов и XML-форматом, закреплённым регулятором
2 Единый backend, отдающий данные для веб-дашборда, мобильного приложения и Smart TV-приложения с разными наборами полей на каждом экране
3 Внутренний вызов между двумя микросервисами с требованием минимальной задержки и строгой типизацией сообщений
4 Простой публичный API погоды с 3 эндпоинтами, который должен легко кэшироваться через CDN

Критерии оценки

Задание Баллы
Задание 1: REST → GraphQL-схема 2
Задание 2: Over-/Under-fetching 2
Задание 3: N+1 problem 2
Задание 4: Ошибки и partial response 2
Задание 5: Чек-лист выбора технологии 2
Итого 10

Дополнительные материалы

📚 Материалы модуля

🖼️ Схема и инфографика

🎬 Видео-лекция

🎬 API и интеграции

📄 Дополнительные материалы (PDF)

📄API Integration Blueprints
Скачать
Спросить ИИ