Внешний 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 key HTTP error / 429 network error invalid response empty 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.

