Skip to content

Latest commit

 

History

History
414 lines (340 loc) · 34.8 KB

File metadata and controls

414 lines (340 loc) · 34.8 KB

🎯 Project 2 Day 8 Answers: GraphQL API в NestJS з Apollo Server (Code-First)

Цей документ містить практичне резюме та аналіз результатів Завдання 1 (index.day_8.ts), присвяченого реалізації власного механізму фільтрації полів та графа вибірки даних (Field Masking & Selection), архітектурний огляд побудови Code-First GraphQL API на базі NestJS та Apollo Server, а також вичерпні відповіді на питання для самоперевірки та технічного інтерв'ю із завдання p-2_d-3.md.


🧩 Практичне резюме: Завдання 1. Алгоритмічний розігрів (Field Masking & Selection)

Код реалізації та перевірочні виклики знаходяться у файлі index.day_8.ts.

🎯 Мета завдання

  1. Відтворити фундаментальний принцип роботи GraphQL на рівні чистих функцій TypeScript: клієнт передає маску бажаних полів (граф вибірки), а сервер повертає лише запитані дані без надлишкової інформації.
  2. Реалізувати строгу типізацію маски через рекурсивні зіставлені типи (Recursive Mapped Types) та умовні типи (Conditional Types).
  3. Забезпечити абсолютну імутабельність вхідного об'єкта data, коректну обробку вкладених структур, масивів та захист від особливостей обробки типів у JavaScript (null, Array).

🛠️ Архітектура рішення: Рекурсивний обхід графа маски

Функція applyFieldMask реалізує підхід «від клієнтського запиту»: ітерація відбувається виключно за ключами переданої маски, що оптимізує кількість перевірок у великих структурах даних.

Вхідні дані: Data (T) + Маска: FieldMask<T>
                   │
                   ▼
┌────────────────────────────────────────────────────────┐
                   Ініціалізація:
             const result = {} as Partial<T>
          Ітерація по keys = Object.keys(mask)
└────────────────────────────────────────────────────────┘
                   │
                   ▼
        Чи існує ключ у data (key in data)?
               ├── НІ ──► Пропустити (continue)
               └── ТАК
                   │
                   ▼
       Аналіз типу значення maskValue = mask[key]
  ┌────────────────────────┬─────────────────────────┬─────────────────────────┐
  │                        │                         │                         │
  ▼                        ▼                         ▼                         ▼
maskValue === true?   isPlainObject(maskValue)   isPlainObject(maskValue)    Інше (false,
                      && isPlainObject(dataVal)? && Array.isArray(dataVal)?  undefined, null)
  │                        │                         │                         │
  ▼                        ▼                         ▼                         ▼
Копіювання скаляра     Рекурсивний виклик         Мапінг елементів          Ігнорування
result[key] =          result[key] =              result[key] =             (поле виключається)
  dataValue              applyFieldMask(...)        dataValue.map(...)

Ключові інженерні акценти:

  1. Рекурсивна типізація маски FieldMask<T>:

    export type FieldMask<T> = {
      [K in keyof T]?: T[K] extends object ? FieldMask<T[K]> | boolean : boolean;
    };
    • Якщо поле є об'єктом, клієнт може або передати вкладену маску FieldMask<T[K]>, або виставити boolean (наприклад, взяти весь об'єкт цілком або відхилити).
    • Для примітивних полів дозволено лише булеве значення.
  2. Захисний Type Guard для відокремлення чистих об'єктів (isPlainObject):

    function isPlainObject(value: unknown): value is Record<string, any> {
      return typeof value === "object" && value !== null && !Array.isArray(value);
    }

    Це запобігає класичним багам JavaScript, де typeof null === 'object' та typeof [] === 'object'.

  3. Взаємовиключні умови та збереження імутабельності: Використання суворого порівняння maskValue === true гарантує, що поле не буде передчасно скопійовано як об'єкт до виконання рекурсивного маскування.


📊 Фактичні результати виконання (index.day_8.ts)

При виконанні маски:

const masked = applyFieldMask(sampleReport, {
  id: true,
  metrics: {
    totalRevenue: true,
    averageCheck: true,
  },
  metadata: false,
});

У консолі отримано чистий зріз даних, де поля category, metrics.totalOrders та блок metadata повністю відфільтровані:

{
  "id": "rep-001",
  "metrics": {
    "totalRevenue": 54000,
    "averageCheck": 450
  }
}

❓ Відповіді на додаткові запитання до Завдання 1

1. Як коректно відокремити звичайний вкладений об'єкт від масиву чи null під час рекурсивного обходу?

  • Проблема: Оператор typeof у JavaScript повертає рядок "object" для трьох принципово різних сутностей: звичайного літералу об'єкта {}, масиву [] та значення null.
  • Рішення: Написання надійного Type Guard:
    function isPlainObject(value: unknown): value is Record<string, any> {
      return typeof value === "object" && value !== null && !Array.isArray(value);
    }
    Для ще суворішої перевірки (щоб відсіяти екземпляри Date, RegExp, Map тощо) використовують перевірку прототипу:
    Object.prototype.toString.call(value) === "[object Object]";

2. Як адаптувати функцію, якщо в даних зустрічається масив об'єктів (наприклад, список товарів), до кожного елемента якого потрібно застосувати однакову маску?

  • Рішення: Додати додаткову гілку обробки, яка перевіряє, чи є значення масивом (Array.isArray(dataValue)), а значення маски — об'єктом (isPlainObject(maskValue)):
    else if (Array.isArray(dataValue) && isPlainObject(maskValue)) {
      result[key] = dataValue.map((item) =>
        isPlainObject(item)
          ? applyFieldMask(item, maskValue as FieldMask<any>)
          : item
      );
    }
    Це дозволяє клієнту описати шаблон вибірки один раз (наприклад, { items: { id: true, name: true } }), а сервер застосує його до кожного елемента масиву в items.

🌐 Практичний огляд Завдань 2 і 3: Apollo Server & Code-First у NestJS

Завдання 2: Налаштування Apollo Server та Code-First схеми

У підході Code-First схема GraphQL автоматично генерується на основі TypeScript-класів та спеціальних декораторів @nestjs/graphql:

  • @ObjectType() — декларує вихідний тип (тип, який повертає сервер).
  • @InputType() — декларує вхідний тип (параметри, які передає клієнт у запитах/мутаціях).
  • @Field() — визначає конкретне поле, його тип та обов'язковість у схемі.

Конфігурація модуля в AppModule:

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  autoSchemaFile: join(process.cwd(), "src/schema.gql"),
  sortSchema: true,
  playground: true,
})

❓ Відповіді на додаткові запитання до Завдання 2

1. У чому полягає відмінність між декораторами @ObjectType() та @InputType() в екосистемі GraphQL?

Критерій @ObjectType() (Output Type) @InputType() (Input Type)
Призначення Описує структуру даних, які сервер повертає клієнту. Описує аргументи/параметри, які клієнт надсилає серверу.
Генерація в SDL Генерує конструкцію type Name { ... } Генерує конструкцію input Name { ... }
Аргументи полів Поля можуть приймати власні аргументи (наприклад: avatar(size: Int): String). Поля не можуть мати аргументів — це суто пласкі/вкладені структури значень.
Підтримка інтерфейсів Може імплементувати interface та брати участь в union. Не може наслідувати інтерфейси чи бути частиною union-типів.
Циклічні посилання Дозволені (наприклад: User посилається на Order, а Order — на User). Суворо заборонені специфікацією GraphQL (циклічний вхідний аргумент унеможливлює серіалізацію).

2. Навіщо в декораторах полів явно вказувати стрілочну функцію типу на зразок @Field(() => Float), якщо в TypeScript поле вже типізовано як number?

  1. Невідповідність систем типів (Type Erasure & Scalar Precision): TypeScript під час компіляції стирає типи. За допомогою reflect-metadata NestJS може дізнатися лише те, що тип поля є базовим конструктором Number. Проте в системі типів GraphQL існують два різних числових скаляри: Int (ціле 32-бітне число) та Float (число з рухомою крапкою). Явна функція @Field(() => Float) вказує компілятору схеми, який саме скаляр підставити.
  2. Вирішення циклічних імпортів (Circular Dependency Resolution): Стрілочна функція типу () => CategoryMetrics є ледачою (lazy evaluation). Вона виконується не в момент завантаження файлу інтерпретатором Node.js, а пізніше — коли всі класи вже ініціалізовані в пам'яті. Це запобігає виникненню помилок ReferenceError: Cannot access 'X' before initialization при взаємних посиланнях типів.

Завдання 3: Resolvers, @ResolveField() та Мутації

Резолвер у NestJS (@Resolver()) відповідає концепції контролера в REST, обробляючи три типи GraphQL операцій:

  1. @Query() — читання даних (аналог HTTP GET).
  2. @Mutation() — модифікація або запис даних (аналог POST/PUT/PATCH/DELETE).
  3. @ResolveField() — точкове обчислення значення окремого поля графа.

❓ Відповіді на додаткові запитання до Завдання 3

1. Коли виконується метод із декоратором @ResolveField(): для кожного елемента масиву окремо чи для всього масиву одразу?

  • Механізм виконання: Метод @ResolveField() викликається індивідуально для кожного елемента масиву, якщо це поле було запрошено клієнтом у запиті.
  • Анатомія процесу: Якщо основний запит повертає 100 звітів [CategoryReport], а клієнт запросив поле formattedSummary:
    1. Метод @Query() виконується 1 раз і повертає масив із 100 об'єктів.
    2. Метод @ResolveField('formattedSummary') викликається 100 разів (для кожного батьківського елемента @Parent()).
  • ⚠️ Архітектурний ризик (Проблема N+1): Якщо всередині @ResolveField() виконується асинхронний запит до бази даних (наприклад, пошук профілю автора для кожного звіту), це призведе до $1 + N$ звернень до БД. Для вирішення цієї проблеми застосовують бібліотеку DataLoader (пакетування та кешування запитів).

2. Яким чином глобальний ValidationPipe підключається для валідації полів усередині InputType у додатку з GraphQL?

У додатках на базі NestJS з GraphQL глобальний пайп валідації працює прозоро для всіх InputType, оскільки NestJS інтегрує пайпи у життєвий цикл виконання резолверів:

  1. Підключення в main.ts:
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
      }),
    );
  2. Або реєстрація через модуль за допомогою APP_PIPE:
    @Module({
      providers: [
        {
          provide: APP_PIPE,
          useClass: ValidationPipe,
        },
      ],
    })
    export class AppModule {}

Коли запит надходить на резолвер, NestJS автоматично інстанціює клас DTO (DateRangeInput) за допомогою class-transformer та валідує правила декораторів (@IsOptional(), @IsDate()) за допомогою class-validator. Якщо валідація не проходить, клієнт отримує стандартну GraphQL помилку з кодом BAD_USER_INPUT.


🎯 Питання для самоперевірки та інтерв'ю


1. Over-fetching та Under-fetching: Як саме GraphQL усуває обидві ці проблеми, типові для класичного REST API?

📌 Сутність проблем у REST API:

REST: Жорсткі фіксовані ендпоінти
┌───────────────────────────┐         ┌───────────────────────────────────────┐
│ GET /api/users/1          │ ──────► │ Повертає 50 полів (потрібно лише 2)   │ ──► OVER-FETCHING
└───────────────────────────┘         └───────────────────────────────────────┘
                                      ┌───────────────────────────────────────┐
┌───────────────────────────┐         │ Потрібні замовлення?                  │
│ GET /api/users/1/orders   │ ──────► │ Робимо 2-й запит до мережі            │ ──► UNDER-FETCHING
└───────────────────────────┘         │ Потрібні деталі товарів?              │     (Waterfalls)
┌───────────────────────────┐         │ Робимо ще N запитів до /products/:id  │
│ GET /api/products/:id     │ ──────► │                                       │
└───────────────────────────┘         └───────────────────────────────────────┘

1. Over-fetching (Надлишкове отримання даних):

  • У REST: Ендпоінт повертає фіксовану структуру даних, визначену бекенд-розробником. Наприклад, щоб відобразити ім'я автора коментаря в мобільному додатку, клієнт викликає GET /api/users/:id і отримує масивний JSON із адресою, хешем пароля, історією логінів, налаштуваннями тощо (десятки кілобайтів зайвих даних).
  • Наслідки: Марнотратне споживання мобільного трафіку, навантаження на мережевий стек, підвищені витрати часу процесора на парсинг великих JSON у клієнтських додатках.
  • Рішення в GraphQL: Клієнт формує запит, де явно вказує лише необхідні поля:
    query {
      user(id: "1") {
        name
      }
    }
    Сервер відправляє JSON виключно з полем name.

2. Under-fetching (Недостатнє отримання даних / Запити-водоспади):

  • У REST: Один ендпоінт рідко містить усі дані, необхідні для рендерингу цілісного екрана. Щоб відобразити замовлення, клієнт спочатку запитує профіль користувача, потім список його замовлень, а потім для кожного замовлення — опис товару. Виникає ефект «мережевого водоспаду» (Network Waterfall / N+1 Round-trips).
  • Наслідки: Велика затримка рендерингу інтерфейсу (Latency), критична для мобільних пристроїв з нестабільним зв'язком.
  • Рішення в GraphQL: Клієнт за один HTTP-запит витягує весь граф взаємопов'язаних сутностей:
    query GetDashboard {
      user(id: "1") {
        name
        orders {
          id
          totalAmount
          items {
            productName
          }
        }
      }
    }
    Сервер самостійно збирає ці дані через резолвери і віддає за один round-trip.

📊 Порівняльна таблиця:

Параметр REST API GraphQL
Хто контролює форму відповіді Сервер (фіксований контракт DTO) Клієнт (декларативний граф вибірки)
Кількість мережевих запитів Часто $N + 1$ для зв'язаних сутностей Завжди 1 комбінований запит
Еволюція API Версіонування шляхів (/v1/, /v2/) Безверсійне API (депрекація полів через @deprecated)

2. Кешування на рівні мережі: Чому стандартні заголовки кешування (наприклад, Cache-Control) практично не працюють "з коробки" для GraphQL-запитів?

🔬 Архітектурні причини:

  1. Уніфікована точка входу (Single Endpoint) та HTTP POST:
    • У REST кожен ресурс має унікальний глобальний ідентифікатор URI (GET /api/products/42, GET /api/categories). Мережеві проксі-сервери, CDN (Cloudflare, Fastly, AWS CloudFront) та браузери кешують відповіді за ключем, який є комбінацією METHOD + URL.
    • У GraphQL всі запити відправляються на один єдиний URI — зазвичай POST /graphql. Згідно зі специфікацією HTTP (RFC 7231 / RFC 9110), метод POST за замовчуванням вважається неідемпотентним і не підлягає стандартному кешуванню проміжними вузлами.
  2. Різна семантика за однакової URL-адреси: Два клієнти можуть надіслати POST /graphql: один запитує лише назву товару, а інший — залишки на складі та ціну. URL однаковий, а вміст тіла запиту і відповіді — принципово різний. Традиційний кеш не аналізує JSON-тіло запиту.
  3. Композитність відповіді (Mixed Freshness): В одному GraphQL-запиті можуть поєднуватися статичні глобальні дані (наприклад, опис категорії товарів, що змінюється раз на місяць) та ультрадинамічні персональні дані (наприклад, кількість бонусів користувача). Неможливо призначити один загальний HTTP-заголовок Cache-Control: max-age=3600 на всю відповідь, оскільки це призведе до показу застарілих або чужих персональних даних.
REST: Кешування за унікальним URL
GET /api/products/42 ────► [ CDN Cache Key: "GET /api/products/42" ] ────► HIT (200 OK з кешу)

GraphQL: Спільний ендпоінт, тіло в POST
POST /graphql { query: { a } } ────► [ CDN: Метод POST — кешування вимкнено ] ──► Йде на бекенд
POST /graphql { query: { b } } ────► [ CDN: Метод POST — кешування вимкнено ] ──► Йде на бекенд

🛡️ Як ця проблема вирішується в сучасній інженерній практиці:

  1. Automatic Persisted Queries (APQ):
    • Клієнт генерує SHA-256 хеш від тексту запиту GraphQL.
    • Перший запит відправляється як GET /graphql?hash=abcdef123456....
    • Оскільки це HTTP GET із детермінованими query-параметрами, CDN та проксі-сервери можуть кешувати його як звичайний REST-запит!
  2. Директива @cacheControl та Cache Scope: Apollo Server підтримує розмітку схеми:
    type Product {
      id: ID!
      title: String! @cacheControl(maxAge: 86400, scope: PUBLIC)
      userCartCount: Int! @cacheControl(maxAge: 0, scope: PRIVATE)
    }
    Сервер автоматично вираховує мінімальний спільний знаменник max-age для всієї відповіді та формує відповідний заголовок Cache-Control.
  3. Клієнтське нормалізоване кешування (Normalized Cache): Клієнтські бібліотеки (Apollo Client, Relay, Urql) кешують дані всередині додатку не за запитами, а за сутностями, розбиваючи відповідь за ключем __typename:id.

3. Статус-коди відповідей: Чому відповіді GraphQL майже завжди приходять зі статусом 200 OK, навіть якщо виконання операції завершилося помилкою? Де саме міститься інформація про збій?

🔬 Принцип розділення рівнів: Транспорт проти Виконання

У GraphQL існує фундаментальне архітектурне розмежування між транспортним рівнем (HTTP Transport Layer) та рівнем виконання GraphQL (Execution Layer):

  • HTTP 200 OK повідомляє клієнту лише одне: «Мережеве з'єднання успішне, сервер отримав повідомлення, успішно розпарсив вхідний запит і повернув валідний GraphQL-документ».
  • Що саме сталося всередині самого запиту (чи знайшовся запис у БД, чи відмовив сторонній мікросервіс) — регулюється внутрішнім протоколом GraphQL.

🧩 Патерн часткового успіху (Partial Success):

На відміну від REST, де помилка одного сервісу призводить до статусу 500 Internal Server Error і клієнт не отримує взагалі нічого, GraphQL підтримує повернення часткових результатів:

{
  "data": {
    "product": {
      "id": "prod-100",
      "name": "Ноутбук Pro",
      "reviews": null
    }
  },
  "errors": [
    {
      "message": "Мікросервіс відгуків тимчасово недоступний (Timeout)",
      "locations": [{ "line": 5, "column": 7 }],
      "path": ["product", "reviews"],
      "extensions": {
        "code": "SERVICE_UNAVAILABLE",
        "timestamp": "2026-03-08T12:00:00Z"
      }
    }
  ]
}

Де саме міститься інформація про збій?

Згідно зі специфікацією GraphQL Specification (Section 7: Response):

  1. data: Містить успішно отримані дані. Якщо резолвер поля викинув виняток і це поле було nullable, поле отримує значення null, а решта графа повертається клієнту цілою!
  2. errors: Масив об'єктів помилок. Кожна помилка містить:
    • message — людиночитаний опис збою.
    • path — точний шлях у дереві відповіді до поля, де стався виняток (["product", "reviews"]).
    • locations — номер рядка та стовпчика у вхідному GraphQL-запиті.
    • extensions — службовий об'єкт для передачі додаткових метаданих (стектрейс, машинний код помилки INTERNAL_SERVER_ERROR, UNAUTHENTICATED).

Note

Коли GraphQL повертає не 200 OK? За сучасною специфікацією GraphQL over HTTP:

  • 400 Bad Request: Якщо вхідний запит має синтаксичні помилки (Syntax Error) або не пройшов валідацію схеми (невідоме поле, відсутній обов'язковий аргумент).
  • 401/403: Якщо HTTP-запит був заблокований гвардом або middleware ще до передачі в рушій GraphQL.
  • 500: Якщо стався критичний крах самого Apollo Server.

4. Code-First vs Schema-First: Які плюси та потенційні мінуси має підхід Code-First у NestJS порівняно зі створенням файлів .graphql вручну?

В екосистемі NestJS існує два принципових підходи до розробки GraphQL API:

  1. Code-First: Розробник пише класи TypeScript, декоруючи їх (@ObjectType(), @Field()). Файл схеми (schema.gql) генерується фреймворком автоматично під час компіляції/старту.
  2. Schema-First: Розробник спочатку пише чистий контракт схеми у файлі .graphql мовою SDL (Schema Definition Language), а потім фреймворк генерує під нього інтерфейси TypeScript або мапить їх на резолвери.
               Code-First (Single Source of Truth: TypeScript)
┌────────────────────────────────┐                 ┌─────────────────────────────┐
│ Класи TS + Декоратори NestJS   │ ──(Генерація)─► │ Фінальна схема schema.gql   │
│ CategoryReport, @Field()       │                 │ (Артефакт для клієнтів)     │
└────────────────────────────────┘                 └─────────────────────────────┘

               Schema-First (Contract-Driven / API-First)
┌────────────────────────────────┐                 ┌─────────────────────────────┐
│ Рукописний файл schema.graphql │ ──(Генерація)─► │ Інтерфейси TS (ts-morph)    │
│ type CategoryReport { ... }    │                 │ + Ручне мапіння резолверів  │
└────────────────────────────────┘                 └─────────────────────────────┘

⚖️ Порівняльний аналіз підходів:

Критерій Code-First Schema-First
Джерело правди TypeScript-код (єдине джерело правди). Файли .graphql (SDL).
Типобезпека Абсолютна (100%). Неможливо випадково повернути поле, якого немає в моделі. Можливий розсинхрон, якщо генератор типів не перезапустився.
Рефакторинг в IDE Швидкий та надійний: комбінація F2 (Rename Symbol) автоматично змінює ім'я по всьому бекенду. Важчий: зміна поля в SDL вимагає ручного оновлення всіх резолверів і DTO.
Валідація DTO Безшовна: один і той самий клас використовує і декоратори GraphQL (@Field), і валідатори (@IsString). Подвійна робота: потрібно описати типи в SDL, а потім окремо створити класи DTO для class-validator.
Узгодження контракту Контракт з'являється лише після написання коду бекенда. Ідеально для крос-команд: фронтенд і бекенд узгоджують схему до написання коду.
Чистота коду ⚠️ Decorator Hell: класи можуть бути перевантажені десятками декораторів (NestJS + TypeORM/Mongoose + Class-Validator). Чистий код резолверів без нагромадження метаданих.
Швидкість старту застосунку Трохи повільніший (вимагає рефлексії типів у рантаймі та побудови схеми). Швидкий парсинг готових текстових файлів SDL.

🏆 Висновок:

  • Code-First (NestJS) — стандарт де-факто для сучасних бекендів на TypeScript. Він усуває дублювання типів, забезпечує безшовну інтеграцію з class-validator та гарантує безпомилковий рефакторинг.
  • Schema-First найкраще підходить для великих розподілених систем, де розробка ведеться різними мовами (наприклад, Go + TypeScript) або де спочатку потрібно зафіксувати спільний контракт між кількома незалежними командами (Design by Contract).