Next.js, внешний API, Zod, нормализация и controlled fallback

от автора

Внешний API возвращает данные в своей модели. Названия полей, вложенные объекты, изображения, цены и правила пагинации принадлежат provider. UI приложения обычно работает с другой моделью. Если компонент читает event._embedded.venues[0].city.name напрямую, структура внешнего ответа распространяется по интерфейсу. Изменение API затрагивает карточки, detail page, search, metadata и тесты.

В App Router внешний запрос можно выполнять на сервере. Server Components работают с fetch, а серверный код не попадает в клиентский bundle. API key при такой схеме остаётся вне браузера. Документация Next.js описывает server‑side data fetching для App Router. В учебном проекте EventMap внешний источник событий проходит через server API слой.

Server boundary

Ключ внешнего сервиса не нужен Client Component. Его читает серверная функция:

export function getTicketmasterApiKey(): string | null {  const key = process.env.TICKETMASTER_API_KEY;  return key && key.trim().length > 0    ? key    : null;}                  

Переменная не имеет префикса NEXT_PUBLIC_.

URL внешнего запроса тоже собирается на сервере:

const TICKETMASTER_EVENTS_URL =  "https://app.ticketmaster.com/discovery/v2/events.json";export function buildEventsUrl({  apiKey,  locale,  city,  keyword,}: BuildEventsUrlInput): string {  void locale;  const url = new URL(TICKETMASTER_EVENTS_URL);  url.searchParams.set("apikey", apiKey);  url.searchParams.set("size", "12");  url.searchParams.set("sort", "date,asc");  url.searchParams.set("locale", "*");  url.searchParams.set(    "startDateTime",    getLiveEventsStartDateTime(),  );  if (city) {    url.searchParams.set("city", city);  }  if (keyword) {    url.searchParams.set("keyword", keyword);  }  return url.toString();}                  

UI не получает API key и не собирает Ticketmaster URL.

JSON после response.json()

TypeScript не проверяет данные, пришедшие по сети. Тип в исходном коде исчезает после компиляции, а внешний сервер может вернуть другую структуру.

После response.json() проект хранит результат как unknown:

const data: unknown = await response.json();                  

Дальше данные проходят runtime‑проверку.

Zod разделяет успешный и неуспешный результат через safeParse. Метод не бросает ZodError, а возвращает объект с success и data либо error. Такая форма подходит для source boundary, где невалидный ответ переводится в другой сценарий. Документация Zod показывает тот же контракт safeParse.

Схема внешнего события

Схема описывает только те части ответа, которые нужны приложению:

import { z } from "zod";export const TicketmasterEventSchema = z.object({  id: z.string(),  name: z.string(),  info: z.string().optional(),  pleaseNote: z.string().optional(),  url: z.string().optional(),  images: z    .array(      z.object({        url: z.string(),        width: z.number().optional(),        height: z.number().optional(),      }),    )    .optional(),  dates: z    .object({      start: z        .object({          localDate: z.string().optional(),          localTime: z.string().optional(),        })        .optional(),    })    .optional(),  classifications: z    .array(      z.object({        genre: z          .object({            name: z.string().optional(),          })          .optional(),        segment: z          .object({            name: z.string().optional(),          })          .optional(),      }),    )    .optional(),});                  

id и name обязательны. Изображения, venue, дата, classification и price могут отсутствовать.

Ответ search endpoint проверяется отдельной схемой:

export const TicketmasterEventsResponseSchema =  z.object({    _embedded: z      .object({        events:          z.array(TicketmasterEventSchema).optional(),      })      .optional(),    page: z      .object({        totalElements: z.number().optional(),        totalPages: z.number().optional(),        number: z.number().optional(),        size: z.number().optional(),      })      .optional(),  });                  

Collection response и отдельное событие имеют разные схемы.

safeParse перед нормализацией

Search получает JSON и проверяет его до обращения к вложенным полям:

const data: unknown = await response.json();const parsed =  TicketmasterEventsResponseSchema.safeParse(data);if (!parsed.success) {  console.warn(    "Ticketmaster search response shape is invalid",  );  return {    ok: false,    reasonKey: "invalid-live-response",  };}                  

Невалидный JSON не попадает в normalizer и React components.

Detail request использует схему одного события:

const data: unknown = await response.json();const parsed =  TicketmasterEventSchema.safeParse(data);if (!parsed.success) {  console.warn(    "Ticketmaster detail response shape is invalid",  );  return null;}return normaliseTicketmasterEvent(parsed.data);                  

После успешного safeParse TypeScript получает тип, выведенный из Zod schema.

Внутренняя модель

UI использует свой тип:

export type EventCardData = {  id: string;  title: string;  city: string;  country: string;  category: string;  dateLabel: string;  venue: string;  description: string;  imageUrl: string;  genreLabel?: string;  priceLabel?: string;  ticketUrl?: string;  timeLabel?: string;};                  

В нём нет _embedded, classifications, priceRanges и других Ticketmaster structures.

Normalizer читает provider model и собирает EventCardData:

export function normaliseTicketmasterEvent(  ticketmasterEvent: TicketmasterEvent,): EventCardData {  const venue =    ticketmasterEvent._embedded?.venues?.[0];  const classification =    ticketmasterEvent.classifications?.[0];  const category =    classification?.segment?.name;  const genre =    classification?.genre?.name;  const description =    ticketmasterEvent.info?.trim() ||    ticketmasterEvent.pleaseNote?.trim() ||    "Event details are coming soon.";  return {    id: ticketmasterEvent.id,    title: ticketmasterEvent.name,    city:      venue?.city?.name ??      "Unknown city",    country:      venue?.country?.name ??      "Unknown country",    category:      category ??      "Event",    dateLabel:      ticketmasterEvent.dates?.start?.localDate ??      "Date TBA",    venue:      venue?.name ??      "Venue TBA",    description,    imageUrl:      pickBestImage(ticketmasterEvent.images),    genreLabel: genre,    ticketUrl:      ticketmasterEvent.url,    timeLabel:      ticketmasterEvent.dates?.start?.localTime        ?.slice(0, 5),  };}                  

Карточка получает одинаковую форму независимо от структуры provider response.

Неполные данные

Внешняя запись может быть валидной по schema и при этом не содержать venue, description или image. Schema оставляет такие поля optional. Normalizer решает, что получит UI.

Description выбирается последовательно:

const description =  ticketmasterEvent.info?.trim() ||  ticketmasterEvent.pleaseNote?.trim() ||  "Event details are coming soon.";                  

Venue и дата получают текстовые fallback:

venue:  venue?.name ?? "Venue TBA",dateLabel:  ticketmasterEvent.dates?.start?.localDate ??  "Date TBA",                  

Компоненту не требуется проверять каждый вложенный provider field.

Выбор изображения

API может вернуть несколько изображений одного события. Первое изображение в массиве не обязательно подходит карточке. Проект фильтрует пустые URL, затем ищет широкое изображение шириной от 600 px:

export const EVENT_PLACEHOLDER_IMAGE =  "/event-placeholder.svg";export function pickBestImage(  images: TicketmasterImage[] | undefined,): string {  if (!images || images.length === 0) {    return EVENT_PLACEHOLDER_IMAGE;  }  const validImages =    images.filter(      (image) =>        image.url.trim().length > 0,    );  if (validImages.length === 0) {    return EVENT_PLACEHOLDER_IMAGE;  }  const wideImage = validImages    .filter(      (image) =>        typeof image.width === "number" &&        image.width >= 600 &&        (image.height ?? 0) <= image.width,    )    .sort(      (a, b) =>        (b.width ?? 0) -        (a.width ?? 0),    )[0];  if (wideImage) {    return wideImage.url;  }  const imagesWithWidth =    validImages.filter(      (image) =>        typeof image.width === "number",    );  if (imagesWithWidth.length === 0) {    return validImages[0].url;  }  return (    imagesWithWidth.sort(      (a, b) =>        (b.width ?? 0) -        (a.width ?? 0),    )[0]?.url ??    EVENT_PLACEHOLDER_IMAGE  );}                  

Если подходящей картинки нет, UI получает локальный placeholder.

HTTP status до Zod

Zod проверяет JSON structure. HTTP status проверяется раньше.

const response = await fetch(url, {  next: {    revalidate:      cachePolicy.liveEventsRevalidate,    tags: [      cachePolicy.tags.liveEvents,    ],  },});if (!response.ok) {  console.warn(    "Ticketmaster search request failed",    response.status,  );  return {    ok: false,    reasonKey: "api-error",  };}                  

401, 429 или 500 не передаются в response.json() как ожидаемый events response.

Ticketmaster документирует quota для Discovery API и возвращает HTTP 429, когда quota превышена. Документация Ticketmaster описывает этот ответ отдельно. В проекте 429 не имеет отдельной runtime‑ветки. Любой !response.ok превращается в api-error, status остаётся в server log.

Ошибка сети

fetch может завершиться исключением до получения HTTP response. Ошибка может возникнуть и во время чтения JSON. Серверная функция перехватывает оба случая:

try {  const response = await fetch(url, {    next: {      revalidate:        cachePolicy.liveEventsRevalidate,      tags: [        cachePolicy.tags.liveEvents,      ],    },  });  if (!response.ok) {    return {      ok: false,      reasonKey: "api-error",    };  }  const data: unknown =    await response.json();  // parse + normalise} catch {  console.warn(    "Ticketmaster search request failed before fallback",  );  return {    ok: false,    reasonKey: "api-error",  };}                  

Ошибка provider не выходит из data function в Server Component.

Результат live source

Search service возвращает discriminated union:

type LiveSearchResult =  | {      ok: true;      events: EventCardData[];      totalCount: number;      totalPages: number;      currentPage: number;    }  | {      ok: false;      reasonKey:        | "missing-api-key"        | "api-error"        | "empty-live-response"        | "invalid-live-response"        | "empty-normalised-response";    };                  

Успешная ветка всегда содержит внутренние EventCardData. Неуспешная ветка содержит причину перехода к следующему источнику.

Пустой ответ

Валидный response может не содержать событий:

const rawEvents =  (parsed.data._embedded?.events ?? [])    .filter(      isTicketmasterEventOnOrAfterStartDate,    );if (rawEvents.length === 0) {  return {    ok: false,    reasonKey: "empty-live-response",  };}                  

Пустой массив отличается от invalid response. Оба состояния не требуют отдельной обработки в React component.

Controlled fallback

Fallback хранится в источнике, которым управляет приложение. Search сначала запрашивает live source:

export async function getSearchPageEvents({  filters,  locale,}: {  filters: SearchFilters;  locale: Locale;}): Promise<SearchPageEventsResult> {  const liveResult =    await searchLiveEvents({      filters,      locale,    });  if (liveResult.ok) {    return {      ...liveResult,      hasPreviousPage:        liveResult.currentPage > 1,      hasNextPage:        liveResult.currentPage <        liveResult.totalPages,      source:        getSourceInfo({          type: "live",          reasonKey: "live-results",        }),    };  }  const filteredEvents =    await searchControlledEvents({      filters,    });  const pagination =    paginateEvents({      events: filteredEvents,      page: filters.page,    });  return {    ...pagination,    source:      getSourceInfo({        type: "fallback",        reasonKey:          liveResult.reasonKey,      }),  };}                  

Live source и fallback возвращают одну SearchPageEventsResult. React component не меняет EventCard для двух источников.

Фильтрация controlled source

Fallback не обязан повторять полный внешний каталог. Он работает с ограниченным набором записей приложения. Категория, город и query применяются к controlled events:

export async function searchControlledEvents({  filters,}: {  filters: SearchFilters;}): Promise<EventCardData[]> {  return controlledEventCards.filter(    (event) =>      matchesCategory(        event,        filters.category,      ) &&      matchesCity(        event,        filters.city,      ) &&      matchesQuery(        event,        filters.q,      ),  );}                  

URL поиска не меняется после перехода с live source на fallback.

Home и search

Одинаковая схема используется на главной странице:

export async function getHomeEvents({  locale,}: GetFeaturedEventsInput): Promise<HomeEventsResult> {  const liveEvents =    await getLiveTicketmasterEvents({      locale,    });  if (liveEvents.length > 0) {    return {      source: "ticketmaster",      events: liveEvents,    };  }  return {    source: "controlled",    events:      await getControlledFeaturedEvents({        locale,      }),  };}                  

Live response с событиями идёт в UI. Пустой или недоступный source заменяется controlled events.

Detail page

У detail route порядок источников другой. Проект сначала ищет event среди controlled data:

export async function fetchEventById({  id,  locale,}: {  id: string;  locale: Locale;}): Promise<EventCardData | null> {  const controlledEvent =    await getControlledEventById({      id,      locale,    });  if (controlledEvent) {    return controlledEvent;  }  return fetchLiveTicketmasterEventById({    id,    locale,  });}                  

Известные controlled IDs не требуют запроса во внешний API. Если ID отсутствует в controlled source, сервер пробует live detail endpoint.

Static params

Controlled IDs используются и при генерации известных detail routes:

export function getControlledEventIds():  string[] {  return controlledEventCards.map(    (event) => event.id,  );}                  

Дальше locale и ID формируют static params:

export function getStaticEventParams():  StaticEventParams[] {  const eventIds =    getControlledEventIds();  return locales.flatMap(    (locale) =>      eventIds.map((id) => ({        locale,        id,      })),  );}                  

Static detail pages строятся из набора, которым управляет приложение. Live IDs остаются доступными через dynamic params.

429 и build

Для known detail pages fetchEventById находит controlled event до внешнего запроса. Такой route не зависит от Ticketmaster response при получении данных события. Home использует другую схему и пробует live source. Там !response.ok и catch возвращают пустой live result, после чего функция выбирает controlled events. Ошибка внешнего API не выбрасывается из этих функций наружу. 429 проходит через тот же код, что остальные non-2xx responses.

Cache policy

Live source не обязательно запрашивать при каждом обращении. Проект хранит интервалы отдельно:

export const cachePolicy = {  liveEventsRevalidate: 300,  liveEventDetailRevalidate: 600,  tags: {    liveEvents: "live-events",    liveEventDetail:      "live-event-detail",  },} as const;                  

Search передаёт policy в server‑side fetch:

const response = await fetch(url, {  next: {    revalidate:      cachePolicy.liveEventsRevalidate,    tags: [      cachePolicy.tags.liveEvents,    ],  },});                  

Next.js расширяет server‑side fetch параметрами next.revalidate и next.tags. revalidate задаёт срок жизни cached resource, tags используются для on‑demand revalidation. API reference fetch описывает оба параметра. Кэш относится к live source. Controlled events лежат внутри приложения и не используют внешний fetch.

Source reason

Data layer сохраняет причину fallback:

export type SearchSourceReason =  | "live-results"  | "missing-api-key"  | "api-error"  | "empty-live-response"  | "invalid-live-response"  | "empty-normalised-response"  | "controlled-fallback";                  

Сервис может различить отсутствующий API key, HTTP/network failure, пустой provider response и невалидную структуру. Эти значения не входят в EventCardData.

Raw data для проверки Zod

В проекте есть отдельный controlled набор raw records. Часть записей намеренно сломана. Одна запись не содержит name:

{  id: "broken-without-name",  location: {    city: "Berlin",    country: "Germany",  },  category: "Design",  date: {    label: "30 July",  },  venue: {    name: "Design Factory",  },  description:    "This raw item should not reach UI.",  price: "free",}                  

Другая запись получает numeric id. Ещё одна не содержит location.city. Все они проходят через:

const parsed =  RawEventSchema.safeParse(rawEvent);if (!parsed.success) {  return [];}return [  normaliseEvent(parsed.data),];                  

Запись, которая не прошла schema, не превращается в карточку.

Tests нормализатора

Нормализатор тестируется отдельно от React components. Один тест проверяет обычный Ticketmaster event:

expect(  normaliseTicketmasterEvent(    ticketmasterEvent,  ),).toEqual({  id: "tm-1",  title:    "Ticketmaster Music Night",  city: "London",  country: "United Kingdom",  category: "Music",  dateLabel: "2026-06-12",  venue: "Roundhouse",  description:    "Live show from Ticketmaster.",  imageUrl: "/tm-wide.jpg",  genreLabel: "Rock",  priceLabel: "from 25 GBP",  ticketUrl:    "https://example.com/tickets",  timeLabel: "19:30",});                  

Другие тесты проверяют pleaseNote, fallback description, time и image selection.

Tests изображений

pickBestImage имеет отдельный набор случаев:

it(  "returns the placeholder for undefined images",  () => {    expect(      pickBestImage(undefined),    ).toBe(      EVENT_PLACEHOLDER_IMAGE,    );  },);it(  "chooses the widest wide image",  () => {    expect(      pickBestImage([        {          url: "/small.jpg",          width: 300,          height: 200,        },        {          url: "/wide-600.jpg",          width: 600,          height: 300,        },        {          url: "/wide-1000.jpg",          width: 1000,          height: 500,        },      ]),    ).toBe("/wide-1000.jpg");  },);                  

Выбор картинки проверяется без внешнего API.

Контур данных

Live path проходит через четыре шага:

Ticketmaster HTTP response        ↓      Zod        ↓   normaliser        ↓ EventCardData        ↓       UI                  

Неуспешный search path идёт через controlled source:

missing API keyHTTP error / 429network errorinvalid responseempty response        ↓controlled events        ↓EventCardData        ↓       UI                  

Server Component получает один тип данных в обеих ветках. API key остаётся на сервере. Provider JSON проверяется до normalizer. Normalizer не передаёт внешнюю структуру в компоненты. Search переключается на controlled source при недоступном live source. Known detail pages сначала читают controlled data. Cache policy задаётся рядом с server fetch.

ссылка на оригинал статьи https://habr.com/ru/articles/1083308/