1. Public API и цель документации
Если подойти к REST Docs с энтузиазмом «а давайте задокументируем вообще всё, что возвращает сервер», вы очень быстро превратите документацию в музей случайностей. Вчера вы добавили поле debugInfo «на минутку», сегодня забыли убрать, завтра клиент радостно начал на него полагаться, а потом вы его удалили — и у вас новый сезон сериала “Breaking Changes: The Reckoning”.
Поэтому в ContentHub мы начинаем с public surface: то, что реально потребляют внешние клиенты (или хотя бы другие части вашей системы). Это прежде всего GET /api/public/articles и GET /api/public/articles/{slug}, плюс обязательные спутники любого публичного API: пагинация и ошибки. Внутренние детали сервиса, «как мы там внутри выбираем статьи из базы», REST Docs не должны пересказывать. Документация — это контракт на границе HTTP, а не автобиография вашего @Service.
Чтобы не сбиться в «документируем всё подряд», полезно держать в голове простую (и очень не романтическую) табличку приоритетов:
| Что документируем | Почему это важно клиенту | Типичные последствия, если не документировать |
|---|---|---|
| Public list/details endpoints | Это главный вход в систему | Клиент пишет код «по догадке» и начинает угадывать контракт |
| Query-параметры пагинации/сортировки/фильтров | Клиенту нужно знать формат и дефолты | Бесконечные “а почему page начинается с 0?”, “а sort как?” |
| Page wrapper (метаданные страницы) | Клиент строит UI пагинации | UI ломается, потому что totalPages внезапно исчез |
| Error contract (ApiProblem) | Ошибки — тоже часть API | Клиент не умеет различать 404 и 409, не показывает нарушения |
Идея лекции: мы документируем экономно, но строго по смыслу. То есть мы описываем важные поля, параметры и структуры, а не делаем полный снимок JSON «как бог пошлёт сериализацию сегодня».
2. Query-параметры GET /api/public/articles
Пагинация и фильтры часто воспринимаются как «ну это же мелочи, клиент сам догадается». А потом вы видите в чате команды: “Ребята, а page у нас с нуля или с единицы?”, “А size ограничен?”, “А сортировка через запятую или через пробел?”. И вот вы уже обсуждаете API не по документации, а по памяти людей. Память людей — штука творческая, поэтому лучше ей не доверять.
REST Docs тут хорош тем, что он заставляет нас честно описать входной контракт: какие query params есть, что они значат, какие из них необязательные, какие у них значения по умолчанию. И главное — мы описываем это в тесте, который уже проверяет, что endpoint отвечает 200 OK и возвращает JSON правильной формы.
Скелет теста для REST Docs обычно выглядит так (мы остаёмся в MVC slice, потому что документируем HTTP-границу, а не базу данных):
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
// MVC-slice тест: поднимаем только веб-слой, чтобы проверять HTTP-контракт
@WebMvcTest(PublicArticleController.class)
@AutoConfigureRestDocs // Включаем генерацию snippets в build/generated-snippets
class PublicArticleRestDocsTest {
@Autowired
MockMvcTester mvc; // Упрощённый клиент для выполнения HTTP-запросов к MockMvc
}
Теперь добавим в тест именно документацию query parameters. Обратите внимание на стиль: сначала мы проверяем статус, и только потом «навешиваем» document(...). Это важно психологически: документация — не главная цель теста, она вторична по отношению к проверке контракта.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentPublicArticlesQueryParams() {
// Делаем реальный (для теста) HTTP-вызов к контроллеру
assertThat(mvc.get().uri("/api/public/articles?page=0&size=20&sort=publishedAt,desc"))
// 1) Сначала проверяем, что happy path действительно happy
.hasStatusOk()
// 2) Только после этого генерируем сниппеты документации
.apply(document("public-articles-list",
queryParameters(
// Описываем входной контракт: какие параметры принимает endpoint
parameterWithName("page").optional().description("Номер страницы (0-based)"),
parameterWithName("size").optional().description("Размер страницы"),
parameterWithName("sort").optional().description("Сортировка в формате field,(asc|desc)")
)
));
}
Пара важных нюансов, которые обычно всплывают не сразу, а после первой «битвы с реальностью».
Во‑первых, optional() здесь — не просто “ну оно как бы необязательное”. Это ещё и про то, что вы, возможно, запускаете тест без этого параметра (например, дефолтный page=0, size=20). Если вы забудете optional(), REST Docs будет ожидать параметр всегда и начнёт ругаться, когда вы вызываете endpoint без sort. Документация должна отражать реальный контракт: параметр необязателен — значит необязателен.
Во‑вторых, описания в description(...) лучше писать как для клиента, который впервые видит API. Фраза “page param” — это почти как написать в коде int x; // x. Формально комментарий есть, практической пользы — ноль.
Чтобы сделать документацию нагляднее, часто удобно держать у себя в голове «внутреннюю таблицу» для параметров пагинации (в REST Docs она напрямую не вставляется, но помогает правильно формулировать дескрипторы):
| Параметр | Пример | Что означает | Вопрос, который снимает |
|---|---|---|---|
| page | 0 | номер страницы | “нумерация с 0 или с 1?” |
| size | 20 | размер страницы | “какой дефолт? есть ли лимит?” |
| sort | publishedAt,desc | сортировка | “как задавать направление сортировки?” |
Если у GET /api/public/articles есть фильтры, например category, его так же надо задокументировать — иначе клиент будет искать «магический параметр» методом археологии:
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentCategoryFilter() {
// Сценарий: клиент фильтрует список статей по категории
assertThat(mvc.get().uri("/api/public/articles?category=java&page=0&size=20"))
.hasStatusOk() // Важно: фильтр не должен "ломать" успешный ответ
.apply(document("public-articles-list-with-category",
queryParameters(
// Фильтры — тоже часть публичного контракта
parameterWithName("category").optional().description("Фильтр по коду категории"),
// Пагинация остаётся такой же
parameterWithName("page").optional().description("Номер страницы (0-based)"),
parameterWithName("size").optional().description("Размер страницы")
)
));
}
Да, snippets будут множиться. И это нормально, пока они не становятся “по одному на каждый чих”. Смысл не в количестве файлов, а в том, чтобы каждый сниппет закрывал отдельный стабильный сценарий.
3. Pagination-ответ: wrapper и content[]
Пагинированный ответ — это место, где “вроде всё понятно” превращается в “почему на клиенте невозможно нормально сделать пагинацию”. Клиенту обычно нужны две вещи: список элементов (у нас это статьи) и метаданные страницы (где мы находимся, сколько всего страниц, какой размер). Если метаданные не документировать, каждый клиент начинает интерпретировать ваш API по‑своему.
В ContentHub мы не возвращаем наружу Page<Article> напрямую (и это правильно), а используем wrapper вроде PageResponse. Поэтому REST Docs в идеале должен отдельно показать структуру wrapper’а и структуру элемента списка. И тут важно не смешивать всё в одну кашу из fieldWithPath("content[].something") без понимания.
Начнём с базового документационного описания PageResponse, не углубляясь в поля элемента. Это как подписать коробку “тут лежит страница результатов” и указать, что внутри есть content, page, size, totalElements, totalPages.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentPublicArticlesPageWrapper() {
assertThat(mvc.get().uri("/api/public/articles?page=0&size=20"))
.hasStatusOk() // Проверяем, что пагинация в принципе работает
.apply(document("public-articles-list-page",
responseFields(
// Описываем wrapper страницы (метаданные + список)
fieldWithPath("content").description("Список статей на странице"),
fieldWithPath("page").description("Номер страницы (0-based)"),
fieldWithPath("size").description("Размер страницы"),
fieldWithPath("totalElements").description("Общее число найденных статей"),
fieldWithPath("totalPages").description("Общее число страниц")
)
));
}
Теперь документируем элементы content[]. И вот здесь многие начинают делать «полный снимок карточки статьи» со всеми полями, включая те, которые клиенту вообще не нужны. В public list обычно достаточно того, что реально отображается в списке: slug, title, summary, publishedAt, что-нибудь про категорию.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentArticleCardInList() {
assertThat(mvc.get().uri("/api/public/articles?page=0&size=20"))
.hasStatusOk()
.apply(document("public-articles-list-content",
responseFields(
// Поля, которые клиент обычно показывает в списке
fieldWithPath("content[].slug").description("Публичный идентификатор статьи"),
fieldWithPath("content[].title").description("Заголовок статьи"),
fieldWithPath("content[].summary").description("Короткое описание"),
fieldWithPath("content[].publishedAt").description("Время публикации"),
// Минимум метаданных страницы, чтобы UI мог строить пагинацию
fieldWithPath("page").description("Номер страницы (0-based)"),
fieldWithPath("size").description("Размер страницы")
)
));
}
Вы заметили, что в этом сниппете мы снова документируем page и size. Это выглядит как дублирование — и оно действительно есть. Но в REST Docs есть баланс: иногда проще (и читаемее) чуть продублировать пару полей, чем городить слишком умные фреймворки и “универсальные сниппеты на все случаи жизни”.
Если хочется аккуратнее, можно вынести общие FieldDescriptor в константу и переиспользовать. Это особенно полезно там, где одни и те же поля pagination или ApiProblem повторяются в нескольких snippets.
Ещё один практический момент: если ваш API эволюционирует и вы добавляете новые поля в ответ (например, content[].readingTimeMinutes), строгое responseFields(...) будет требовать описать каждое поле. Это хорошо, когда вы хотите жёсткий контроль, но иногда нужно документировать только стабильный минимум. Тогда полезен “расслабленный” вариант — relaxedResponseFields(...), который не падает, если есть недокументированные поля.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.relaxedResponseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentOnlyStableFields() {
assertThat(mvc.get().uri("/api/public/articles?page=0&size=20"))
.hasStatusOk()
.apply(document("public-articles-list-relaxed",
relaxedResponseFields(
// Документируем только "стабильное ядро", остальное игнорируем
fieldWithPath("content[].slug").description("Публичный идентификатор статьи"),
fieldWithPath("content[].title").description("Заголовок статьи")
)
));
}
Это не “читерство”. Это осознанный компромисс: вы выбираете, что для вас важнее — строгая полнота документации или устойчивость к добавлению второстепенных полей. Для public API обычно всё-таки лучше держать документацию ближе к контракту, а контракт — стабильным. Но когда вы только начинаете жить с REST Docs, relaxed-режим может спасти психику.
4. Details endpoint: GET /api/public/articles/{slug}
Есть соблазн задокументировать list и details одним сниппетом, потому что “ну там же статья, какая разница”. Разница как минимум в том, что details обычно содержит больше данных, а list — более компактный. И если вы смешаете их в одну «универсальную документацию статьи», клиент будет пытаться использовать list как details или наоборот, а дальше начинается классическое “почему у нас body всегда null?”.
Поэтому мы делаем отдельный сниппет для details endpoint. И сразу появляется ещё одна деталь контракта: path-параметр slug. Даже если вы не используете pathParameters(...) (а он есть в REST Docs), уже по сниппету должно быть понятно, что slug — это ключ запроса и что именно туда нужно подставлять.
Начнём с документации ответа details:
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentPublicArticleDetails() {
// Сценарий: клиент открывает страницу конкретной статьи
assertThat(mvc.get().uri("/api/public/articles/java-testing-basics"))
.hasStatusOk() // Убедились, что статья доступна и endpoint живой
.apply(document("public-article-details",
responseFields(
// Details обычно богаче, чем list-card, но всё равно документируем по смыслу
fieldWithPath("slug").description("Публичный идентификатор статьи"),
fieldWithPath("title").description("Заголовок статьи"),
fieldWithPath("summary").description("Короткое описание"),
fieldWithPath("body").description("Полный текст статьи"),
fieldWithPath("publishedAt").description("Время публикации")
)
));
}
Здесь снова работает правило экономии: не нужно описывать “все поля доменной модели”, если они не являются частью стабильного public контракта. Например, version (optimistic lock) — вообще не про клиента public API. То, что вам важно в базе и JPA, не обязательно важно внешнему потребителю. Документация помогает вам заметить эти смешения: если рука тянется документировать version и authorUsername в публичном endpoint — это хороший момент остановиться и спросить “а клиенту правда это нужно?”.
Если вы хотите добавить документирование path параметра (что обычно полезно), это можно сделать так же “поверх” проверенного сценария:
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentSlugPathParam() {
assertThat(mvc.get().uri("/api/public/articles/java-testing-basics"))
.hasStatusOk()
.apply(document("public-article-details-path",
pathParameters(
// Документируем "ключ" запроса: что именно нужно подставить в URL
parameterWithName("slug").description("Slug опубликованной статьи")
)
));
}
С точки зрения читателя документации это снимает кучу вопросов: “это id?”, “это UUID?”, “это человекочитаемый путь?”. Мы явно говорим: это slug, публичный идентификатор.
5. Ошибки API: 404 и 400 для пагинации
Ошибка в API — это не “стыдно, спрячем”, а такой же контракт, как и 200 OK. Более того, ошибки часто важнее: happy path клиент может «как-то обработать», а вот если он не понимает, что означает errorCode, он не сможет показать человеку нормальное сообщение и будет просто писать “Oops”.
В ContentHub у нас есть стабильный формат ApiProblem (ProblemDetail-compatible стиль): поля вроде type, title, status, detail, errorCode, плюс иногда violations. Значит, мы можем и должны документировать этот payload. Удобная стратегия — сделать отдельные snippets под разные классы ошибок: не найдено (404), невалидные параметры пагинации (400), и при желании — общий “problem payload overview”.
Документируем 404 “статья не найдена”. С точки зрения теста, нам важно вызвать endpoint с отсутствующим slug и получить статус 404. Как именно сервис к этому пришёл, для REST Docs теста вторично; обычно это решается стабом через @MockitoBean, но документируем мы не мок, а сам ответ на HTTP‑границе.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentArticleNotFound() {
// Сценарий: клиент запросил статью, которой нет
assertThat(mvc.get().uri("/api/public/articles/missing-slug"))
.hasStatusNotFound() // Контракт: для отсутствующей статьи возвращаем 404
.apply(document("public-article-not-found",
responseFields(
// Документируем полезный минимум, который нужен клиенту для обработки ошибки
fieldWithPath("title").description("Короткое имя ошибки"),
fieldWithPath("status").description("HTTP-статус"),
fieldWithPath("detail").description("Человеко-читаемое описание"),
fieldWithPath("errorCode").description("Стабильный код ошибки")
)
));
}
Заметьте: мы не документируем здесь все поля ApiProblem. Это снова осознанная экономия. Например, instance и type бывают полезны, но если они у вас ещё не устоялись или иногда отличаются, лучше не делать их центральными. Вы можете документировать их позже или отметить как optional. В REST Docs fieldWithPath(...).optional() работает и для response fields.
Теперь документируем 400 Bad Request для пагинации, если, например, клиент передал некорректный size. В идеальном мире это будет validation error с violations (например, size должен быть > 0 и <= 100). И вот это как раз то, что клиенту нужно: список нарушений.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentInvalidPaginationError() {
// Сценарий: клиент передал некорректный размер страницы
assertThat(mvc.get().uri("/api/public/articles?page=0&size=-1"))
.hasStatusBadRequest() // Контракт: невалидные параметры -> 400
.apply(document("public-articles-invalid-pagination",
responseFields(
// Базовые поля ошибки
fieldWithPath("status").description("HTTP-статус"),
fieldWithPath("errorCode").description("Стабильный код ошибки"),
// Нарушения валидации: клиент может подсветить поле и показать сообщение
fieldWithPath("violations[].field").description("Поле с нарушением"),
fieldWithPath("violations[].message").description("Описание нарушения")
)
));
}
Если violations не всегда присутствует (например, для 404 его нет), тогда в “общем” сниппете можно пометить его как optional. Но я бы не делал один “универсальный error snippet на все случаи” сразу, потому что он получается слишком размытым. Практичнее иметь 2–3 конкретных документационных сценария: один для not-found, один для validation, и всё.
В итоге у нас вырисовывается очень здоровая мысль: у одного endpoint’а нормально иметь два документационных сниппета — happy path и error path. Клиенту это часто даже важнее, чем “ещё одно поле в успешном ответе”.
Чтобы закрепить картинку, вот мини-схема того, как это выглядит в голове (и в проекте) на практике:
flowchart TD
A["MVC-тест (MockMvcTester)"] --> B["assertions: статус, JSON, headers"]
B --> C["document(...): query params / response fields"]
C --> D["generated snippets"]
D --> E["Документация API (собирается из snippets)"]
Важный акцент: документация стоит на плечах проверенного сценария. Мы не рисуем контракт руками; мы вырастаем его из теста, который уже доказал, что контракт реально такой.
6. Живые REST Docs: snippets и стабильные имена
Для public API ContentHub удобнее держаться явных path-like имён snippets: по названию сразу видно endpoint и сценарий. {method-name} остаётся рабочей альтернативой, но здесь важнее, чтобы дерево generated-snippets само рассказывало, что именно задокументировано.
Когда REST Docs только появляется в проекте, первая эмоция часто такая: “О, круто, теперь у нас будет документация!”. Вторая эмоция через неделю: “О нет, теперь у нас ещё и документация…”. Чтобы REST Docs не стал «налогом на жизнь», нужно две вещи: разумно выбирать, что документировать, и сделать snippets переиспользуемыми, но без магии.
Начнём с имён snippets. Самая практичная схема — использовать имена, которые читаются как путь: зона API + endpoint + сценарий. Тогда build/generated-snippets становится почти само-документируемым деревом. Например:
| Snippet name | Что это |
|---|---|
| public-articles-list | public list happy path |
| public-article-details | public details happy path |
| public-article-not-found | 404 на details |
| public-articles-invalid-pagination | 400 на list |
Это не единственно правильный подход, но он помогает не превратить snippets в набор “doc1”, “doc2”, “doc-final-final2”.
Теперь — переиспользование. Самый «безопасный» вариант переиспользования — не прятать весь тест в DSL, а вынести только повторяемые куски: например, описание базовых полей ApiProblem. Тогда документация становится единообразной, а тест остаётся читабельным.
import org.springframework.restdocs.payload.FieldDescriptor;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
// Небольшой хелпер: держим общие FieldDescriptor в одном месте
final class ApiProblemDoc {
static FieldDescriptor[] basic() {
return new FieldDescriptor[] {
// Минимальный контракт ошибки, который мы считаем стабильным
fieldWithPath("title").description("Короткое имя ошибки"),
fieldWithPath("status").description("HTTP-статус"),
fieldWithPath("detail").description("Описание ошибки"),
fieldWithPath("errorCode").description("Стабильный код ошибки")
};
}
}
Дальше вы используете это в тесте и добавляете специфичные поля конкретного сценария, например violations. Да, нужно будет склеить массивы дескрипторов (можно вручную, можно маленьким helper’ом), но идея простая: общий минимум живёт в одном месте, а “специфика” остаётся в тесте.
Ещё один принцип, который спасает от “сниппетов ради сниппетов”: документируйте то, что действительно стабильно и полезно. Если поле вычисляется динамически и его смысл клиенту не нужен, не делайте его центральным в документации. REST Docs — это не обязанность описать каждый байт, это обязанность описать контракт.
И последнее: не бойтесь того, что один endpoint будет иметь несколько snippets. Для клиентов это нормально: один и тот же URL имеет разные сценарии и разные ответы. Документация, которая честно показывает “успешный ответ” и “ошибка” — это взрослая документация. Документация, которая делает вид, что ошибок не существует, — это как инструкция к чайнику без раздела “что делать, если он не включается”.
7. Типичные ошибки в Spring REST Docs
Ошибка №1: документировать до того, как вы проверили контракт assertions.
Очень легко увлечься REST Docs и начать писать document(...) как главную цель теста. В итоге вы документируете сценарий, который на самом деле никак не проверен. Это превращает документацию в красивую теорию. Лекарство простое и скучное: сначала статус, заголовки и смысловые поля ответа проверяются assertions, а уже потом поверх этого цепляется document(...).
Ошибка №2: пытаться описать в документации «весь JSON целиком», включая случайные поля.
Снапшот “весь ответ как есть” выглядит соблазнительно, но он очень быстро ломается от любого рефакторинга и рождает страх менять контракт даже там, где это безопасно. В REST Docs лучше документировать стабильные и полезные поля, а не превращать документацию в музей сериализации. Если хочется частичной документации, используйте relaxed-подход или документируйте только важные куски.
Ошибка №3: документировать только happy path и игнорировать error contract.
На практике клиенты чаще спотыкаются об ошибки, чем о успешные ответы. Если вы задокументировали 200 OK, но не задокументировали ApiProblem для 404 и 400, клиент всё равно будет писать обработку ошибок «по догадке». Потом ваша команда получит вопросы: “а как отличить ARTICLE_NOT_FOUND от ACCESS_DENIED?” и “а violations где?”. Ошибки — часть API, и REST Docs как раз удобен тем, что заставляет это признать.
Ошибка №4: смешивать документацию пагинации и документацию элемента списка так, что никто ничего не понимает.
Если вы в одном сниппете пытаетесь описать и page/size/totalPages, и content[].slug/title/summary, а ещё и вложенные поля категории, документация становится нечитаемой. Лучше документировать pagination wrapper как структуру страницы и отдельно — структуру элемента (article card). Даже если это два snippets, читатель будет вам благодарен.
Ошибка №5: нестабильные имена snippets и “doc-final3” в репозитории.
Поначалу кажется, что имя — мелочь. Потом у вас 40 сниппетов, и вы больше времени тратите на поиск “как же назывался тот сниппет про not found”, чем на реальную работу. Стабильная схема именования (public/list/details/error) — это не бюрократия, а навигация по проекту, когда тестов и документации становится много.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ