- BrainTools - https://www.braintools.ru -
Я делаю hhbro.ru [1] один — и как разработчик, и как продукт. У проекта несколько клиентов (web, browser extension, desktop), а домены Resume и Vacancy постоянно эволюционируют: появляются новые поля, меняются структуры, добавляются платные/бесплатные флоу, кеширование, экспорт, AI-анализы.
В какой-то момент стало очевидно: самая дорогая ошибка [2] — не “написал баг”, а “не синхронизировал контракт данных”. Это та категория проблем, которая:
проявляется не сразу (часто у части пользователей/клиентов),
плохо воспроизводится,
быстро размазывается по коду «временными костылями»,
и съедает время, которое должно идти в продукт.
Эта статья полезна, если у тебя:
больше одного клиента (хотя бы web + mobile/desktop/extension),
API живёт и меняется,
данные не плоские (JSON-поля, вложенные структуры, “object|null”, массивы, версии),
хочется воспроизводимого процесса, а не “держим в голове”.
По сути это не “история про OpenAPI”, а инструкция, как сделать так, чтобы консистентность стала свойством системы, а не внимательности разработчика.
OpenAPI — единственный источник истины (структуры request/response, версии).
Из OpenAPI автоматически генерятся:
TypeScript SDK (клиенты),
Zod-схемы (runtime-валидация ответов).
На фронте запрещены ручные axios/fetch, локальные DTO/типы и response.data [3] без validateResponse().
На бэке — обязательные OpenAPI-схемы для каждого endpoint’а и контрактные unit-тесты, которые валят сборку, если схема забыта.
Результат: изменения в API перестали «внезапно» ломать клиенты. Любая неконсистентность проявляется на этапе генерации/валидации/тестов.
Когда у продукта один клиент и один разработчик, можно жить на «договорённостях». Но как только появляется:
Web (основной интерфейс),
Browser extension (обход ограничений источников и быстрый анализ),
Desktop (работа с несколькими резюме одновременно — удобно карьерным консультантам),
…а API при этом активно меняется, вылезают симптомы:
клиент продолжает ожидать старое поле → рантайм падение;
бэкенд уже вернул новую структуру → фронт не знает, что с ней делать;
в одном клиенте «пофиксили костылём», в другом забыли → хаос;
типы TS «успокаивают», но в рантайме прилетает не то.
В моём случае это совпало с тем, что вокруг домена Resume разрослась функциональность:
импорт резюме из файла → извлечение текста → AI-распознавание структуры;
AI-улучшение/генерация CV;
экспорт в PDF/DOCX/RTF;
анализ карьеры и gaps по навыкам;
связка с Vacancy/MultiSearch для matching и массовых сценариев.
И в какой-то момент стало ясно: надо перестать надеяться на внимательность разработчика. Нужна система, где консистентность — это свойство процесса.
Я выбрал простой «рельс»:
OpenAPI фиксирует контракт: какие поля, какие типы, какие структуры, какие версии API.
Всё, что может быть сгенерировано из контракта — генерируется, а не пишется вручную.
В рантайме клиент подтверждает, что получил именно контракт, а не «что-то похожее».
Почему важно именно runtime:
типы TS не спасают, если API изменилось, а клиент не пересобран;
часть клиентов может жить отдельно (десктоп, расширение), и «заказчик багов» — пользователь.
Пример для домена Resume (но так же у меня устроен эталонный Vacancy):
www/
app/
Containers/
AppSection/
Resume/
Actions/
ListResumesAction.php
ImportResumeFromFileAction.php
RecognizeResumeAction.php
EnhanceResumeAction.php
ExportResumeAction.php
GenerateAICVAction.php
AnalyzeCareerPathAction.php
AnalyzeSkillsGapAction.php
...
Tasks/
ExtractTextFromFileTask.php
ResumeExportPipelineTask.php
AnalyzeCareerPathTask.php
AnalyzeSkillsGapTask.php
...
Models/
Resume.php
ResumeCategory.php
ResumeCareerAnalysis.php
...
Data/
DTOs/
Entities/
ResumeDTO.php
CareerPathDTO.php
SkillsGapDTO.php
Requests/
Resume/
UpdateResumeRequestDTO.php
ImportResumeFromFileRequestDTO.php
RecognizeResumeRequestDTO.php
...
Responses/
Resume/
GetResumeResponseDTO.php
ListResumesResponseDTO.php
ImportResumeFromFileResponseDTO.php
...
CareerPathResponseDTO.php
SkillsGapResponseDTO.php
Mappers/
ResumeMapper.php
Schemas/
V1/
Entities/
ResumeSchema.php (например: ResumeV1)
Requests/
Resume/
UpdateResumeRequestSchema.php
RecognizeResumeRequestSchema.php
AnalyzeCareerPathRequestSchema.php
AnalyzeSkillsGapRequestSchema.php
...
Responses/
Resume/
GetResumeResponseSchema.php
ListResumesResponseSchema.php
ImportResumeFromFileResponseSchema.php
...
Resume/
CareerPathResponseSchema.php
SkillsGapResponseSchema.php
UI/
API/
Controllers/
Entities/
ResumeController.php (реализация: orchestration)
V1/
ResumeController.php (только OpenAPI attrs + parent::)
Routes/
V1/
ListResumes.v1.private.php
GetResume.v1.private.php
UploadResume.v1.private.php
RecognizeResume.v1.private.php
...
Tests/
Functional/
API/
ListResumesTest.php
AnalyzeCareerPathTest.php
AnalyzeSkillsGapTest.php
...
Unit/
OpenAPI/
ResumeOpenAPIComponentsTest.php
ResumeCategoryOpenAPIComponentsTest.php
resources/
js/
services/
resume.js (SDK + validateResponse)
sdk/
v1/ (сгенерированный TS SDK)
sdk/
zod/
v1/ (сгенерированные Zod схемы)
utils/
validateResponse.js
services/
sdk-config.ts
Короткая логика [4] «зачем так»:
UI/API/Controllers/Entities: код поведения [5] endpoint’ов (орchestrator), без OpenAPI-шумов.
UI/API/Controllers/V1: версионная поверхность + OpenAPI-атрибуты, без логики (только parent::...).
Data/Schemas/V1: контрактные схемы request/response/entities для генерации OpenAPI.
Data/DTOs/*: строгие DTO для входа и выхода (границы).
Actions/Tasks: бизнес-логика и инфраструктура без смешивания с HTTP.
Tests/Unit/OpenAPI: страховка от человеческой ошибки “забыл схему”.
resources/js/sdk + resources/js/sdk/zod: всё, что нужно клиентам, генерируется автоматически.
Чтобы читатель мог повторить путь, вот короткий чек-лист:
Сделай реализацию в UI/API/Controllers/Entities/*Controller + Actions/Tasks.
Добавь версионный метод в UI/API/Controllers/V1/*Controller:
OpenAPI attributes (path, operationId, requestBody, responses)
return parent::method(...)
Создай схемы:
Data/Schemas/V1/Requests/...*RequestSchema.php
Data/Schemas/V1/Responses/...*ResponseSchema.php
Прогони генерацию:
php artisan sdk:generate
На фронте используй только:
SDK (@/sdk/v1/api)
Zod (@/sdk/zod/v1)
validateResponse()
В Apiato я держу фактическую реализацию в:
UI/API/Controllers/Entities/*Controller
Этот слой делает:
оркестрацию,
преобразование HTTP → DTO,
вызов Actions/Tasks,
формирование ответа.
Бизнес-логика живёт в Actions/Tasks.
В UI/API/Controllers/V1/*Controller я оставляю «тонкую обёртку»:
на методах — OpenAPI-атрибуты (path/operationId/request/response schemas),
сам метод просто вызывает parent::method(...).
Смысл: контракт и версия вынесены на поверхность, а логика не дублируется.
Пример (упрощённо):
#[OAGet(
path: "/v1/resumes/{id}",
operationId: "getResumeV1",
responses: [
new OAResponse(
response: 200,
content: new OAJsonContent(ref: "#/components/schemas/GetResumeResponseV1")
),
]
)]
public function show(int $id): JsonResponse
{
return parent::show($id);
}
В проекте есть команда:
php artisan api:openapi:generate
Концептуально она:
сканирует app/Ship/OpenApi (общие Info/Servers/security),
затем проходит по доменам app/Containers/AppSection/*,
берёт только:
UI/API/Controllers/V1 (и V2),
Data/Schemas/V1 (и V2),
Enums,
формирует storage/openapi/openapi-v1.json и openapi-v2.json.
То есть я документирую не роуты Laravel, а контрактную поверхность.
Команда:
php artisan sdk:generate
# или точечно:
php artisan sdk:generate --versions=v1
Она делает три вещи:
запускает api:openapi:generate
генерирует TypeScript SDK (OpenAPI generator, typescript-axios)
генерирует Zod схемы (openapi-to-zod)
Выходные каталоги:
resources/js/sdk/v1/ — SDK-клиент и модели
resources/js/sdk/zod/v1/ — Zod-схемы
Потому что SDK — это «сшивка» клиента с контрактом:
имена методов (operationId),
параметры,
структуры request/response,
ошибки в типах после генерации — это сигнал, что контракт менялся.
Потому что Zod — это runtime-барьер:
если бэк вернул не контракт — клиент падает с понятной причиной (и не начинает «рисовать мусор»),
если кто-то “слегка поправил” данные в ручном коде — Zod это ловит.
Это важно: если в проекте позволить «маленькие мапперы» на фронте, они быстро превращаются в свалку. Поэтому я зафиксировал правила как часть архитектуры.
В Vacancy у меня эталонный сервисный слой:
импорт SDK-клиентов из @/sdk/v1/api,
импорт схем из @/sdk/zod/v1,
каждый ответ проходит validateResponse().
Пример паттерна:
import { ResumesApi } from '@/sdk/v1/api';
import { createV1Config } from '@/services/sdk-config';
import { validateResponse } from '@/utils/validateResponse';
import GetResumeResponseV1Schema from '@/sdk/zod/v1/zod-GetResumeResponseV1';
export async function getResume(id: number) {
const api = new ResumesApi(createV1Config());
const response = await api.getResumeV1(id);
const validated = validateResponse(response.data, GetResumeResponseV1Schema);
return validated.data;
}
А если endpoint отдаёт файл (PDF/DOCX/RTF), там честно остаётся Blob без Zod — потому что это бинарный ответ.
Самая частая человеческая ошибка: endpoint написал, а *ResponseSchema для OpenAPI забыл.
Чтобы это ловилось автоматически, я добавил доменные тесты, которые:
требуют наличие storage/openapi/openapi-v1.json (и говорят “запусти sdk:generate”),
вынимают components.schemas,
проходят по endpoints определённого tag,
проверяют, что каждый 2xx ответ ссылается на существующую schema.
Упрощённый пример:
public function test_resume_endpoints_have_valid_response_schemas(): void
{
$doc = json_decode(file_get_contents(storage_path('openapi/openapi-v1.json')), true);
$schemas = $doc['components']['schemas'] ?? [];
foreach ($doc['paths'] as $path => $methods) {
foreach ($methods as $method => $op) {
if (!in_array('Resumes', $op['tags'] ?? [], true)) {
continue;
}
$ref = $op['responses']['200']['content']['application/json']['schema']['$ref'] ?? null;
$this->assertNotNull($ref, "No schema for {$method} {$path}");
$name = basename($ref);
$this->assertArrayHasKey($name, $schemas, "Schema {$name} not found");
}
}
}
Для бинарных ответов (экспорт) тест делает исключение по mediaType.
Одна из самых противных проблем — поля «объект или null», которые в базе иногда оказываются:
[] (пустой массив),
списком объектов (JSON list),
объектом с типами «строка вместо числа».
Это ломает Zod и ломает клиентов.
Я нормализую такие поля на бэкенде в одном месте (mapper), чтобы API всегда отдавал контракт.
Принципы нормализации:
[] → null, если по контракту ожидается object|null
list → либо unwrap первого объекта, либо null (по договору)
приведение типов (например id: "123" → id: 123) только в пределах безопасного правила
В домене Resume это особенно важно для area, contacts, education и подобных JSON-полей.
Я оформил это не как «рекомендации», а как правила процесса:
перед изменениями фронта — grep на запрещённые паттерны;
перед изменениями API/DTO/Schema — обязательный sdk:generate;
SDK/Zod руками не правятся;
контрактные тесты по доменам — обязательны.
Почему это важно: это превращает консистентность данных из «настроения разработчика» в системное свойство.
Если ты хочешь внедрить это у себя — делай так:
Выбери один эталонный домен и доведи его до “идеала”:
OpenAPI атрибуты на версиях,
request/response schemas,
фронт: SDK + Zod + validateResponse.
Раздели контроллеры:
Entities/*Controller — реализация,
V1/*Controller ��� только OpenAPI и parent::....
Сделай генерацию:
api:openapi:generate (строгий JSON),
sdk:generate (SDK + Zod).
Добавь контрактные тесты домена:
“все endpoints под тегом X имеют валидные схемы”.
Запрети обходные пути на фронте:
никакого ручного axios/fetch “пока нет времени”,
никакого response.data [3] без validateResponse,
никакого “адаптера”, который скрывает несоответствие API.
В hhbro.ru [1] это напрямую влияет на ценность:
AI анализ вакансии (red flags, salary, interview prep),
мультипоиск и массовые сценарии,
генерация/улучшение резюме под ATS,
расширение/десктоп, которые должны жить независимо,
монетизация через кредиты, кеширование и повторное использование результатов.
Если контракт дрейфует — продукт выглядит «сырой». Если контракт стабилен — ты можешь быстро добавлять фичи, не боясь снежного кома регрессий.
сделать CI-джобу, которая:
запускает sdk:generate,
проверяет, что рабочее дерево не изменилось (значит в PR не забыли сгенерить артефакты),
гоняет доменные OpenAPI-тесты.
закрывать “дыры” OpenAPI так, чтобы на фронте не было прямых вызовов без SDK/Zod.
Если ты дочитала до конца и хочешь внедрить такой подход в своём проекте — начни с одного домена, сделай его эталонным, и дальше «раскатывай рельсы» по остальным: это даёт самый быстрый эффект на качество и скорость разработки.
Автор: DVZakusilo
Источник [6]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/24208
URLs in this post:
[1] hhbro.ru: http://hhbro.ru
[2] ошибка: http://www.braintools.ru/article/4192
[3] response.data: http://response.data
[4] логика: http://www.braintools.ru/article/7640
[5] поведения: http://www.braintools.ru/article/9372
[6] Источник: https://habr.com/ru/articles/984354/?utm_source=habrahabr&utm_medium=rss&utm_campaign=984354
Нажмите здесь для печати.