108 lines
7.7 KiB
Markdown
108 lines
7.7 KiB
Markdown
---
|
||
title: openapi-generator + OpenAPI 3.1 — gotchas генерации клиентов
|
||
type: concept
|
||
tags: [openapi, openapi-generator, codegen, python, sched, ff, api-contract]
|
||
related: [[sched-fnf-r8-verification-stand]]
|
||
updated: 2026-08-18
|
||
---
|
||
|
||
# openapi-generator + OpenAPI 3.1 — gotchas
|
||
|
||
Проверено вживую на F&F-раундах sched «клиент на любом ЯП одной командой»
|
||
(2026-08-18): Python-клиент из спеки `@sched/admin-api` через
|
||
`openapi-generator` **7.24.0** (`-g python`, pydantic v2). Спеку НЕ чинили —
|
||
обходили (round 1, 0.1.2). Round 2 (0.2.0): все гочи 1–4 **закрыты фиксами
|
||
спеки/сервера**, raw-обход удалён — см. «Статус после round 2» ниже.
|
||
|
||
## Статус после round 2 (0.2.0 / daemon 0.3.5)
|
||
|
||
Все 5 находок round 1 подтверждены закрытыми (ретест 2026-08-18, отчёт
|
||
`sched/.agents/inbox/2026-08-18T22-55-00Z-admin-openapi-fixes-round2-retest.md`):
|
||
|
||
- **Гоча 1 (wrapped-ответы) — FIXED (спека).** Мутации теперь объявляют
|
||
`{task}/{run}/{schedule}` (response-компоненты TaskMutation/RunMutation/
|
||
ScheduleMutation), `GET /runs/{id}?logFromOffset` — RunLogChunk (allOf
|
||
RunRecord + logChunk/logTotalLength). Генератор выдаёт **InlineObjectN**-
|
||
модели (поля task/run/schedule/ok), десериализация нативная. `raw_ops.py`
|
||
из примера **удалён**.
|
||
- **Гоча 2 (const:true) — FIXED.** `const` убран из спеки; `Health200Response.ok`
|
||
= обычный `StrictBool`.
|
||
- **Гоча 3 (oneOf+discriminator) — FIXED.** `Schedule` стал плоской схемой
|
||
(`kind` + опциональные `cron/timezone/ms/at`); `rec.schedule.cron` читается
|
||
с типизированной модели. Normalization живьём: `once`→`{kind:once, at:ISO}`,
|
||
`interval`→`{kind:interval, ms}`.
|
||
- **F-1 (health auth) — FIXED (сервер).** `/health` всегда открыт, auth —
|
||
после health-роута; прочие роуты с ключом 401.
|
||
- **F-5 (dedupKey fail-fast) — FIXED (сервер).** POST/PATCH /schedules с
|
||
неизвестным top-level полем → 400 с именем поля.
|
||
- Итог ретеста: **11/11 ранее-сырых операций идут через типизированный клиент,
|
||
сьют 24/24, smoke 19/19** (коммит sched `d658ff0`).
|
||
|
||
Гочи 1–3 ниже — описание сломанного состояния round 1 (0.1.2) + паттерн обхода
|
||
(потребительский escape-hatch, если спека где-то ещё сырая).
|
||
|
||
## Гоча 1 — ответы `{}` в спеке ломают десериализацию (главная)
|
||
|
||
Если операция объявляет ответ `{}` (пустая схема), а сервер реально отдаёт
|
||
объект (`{task: …}`, `{run: …}`, `{schedule: …}`, `{logChunk, logTotalLength}`),
|
||
сгенерированный клиент валится на **pydantic ValidationError** — он пытается
|
||
десериализовать wrapped-тело в объявленную модель, а требуемых полей на верхнем
|
||
уровне нет (все None).
|
||
|
||
- Симптом: `ValidationError` на 2xx с полями `input_value=None`.
|
||
- Фикс (у имплементера спеки): объявлять реальные схемы ответов
|
||
(`{task: TaskRecord}` и т.п.). Без этого ни один генератор не даёт рабочий клиент.
|
||
- Обход (у потребителя): raw-транспорт + ручная валидация:
|
||
```python
|
||
resp = client.call_api("POST", client.configuration.host + "/tasks", body=body)
|
||
data = json.loads(resp.read()) # resp.data может быть None до read()
|
||
rec = TaskRecord.model_validate(data["task"])
|
||
```
|
||
Важно: в raw-вызове `call_api` **НЕ делает snake→camel конвертацию** запроса —
|
||
сериализуй через `model_dump(exclude_none=True, by_alias=True)`.
|
||
|
||
## Гоча 2 — `const: true` компилится в строковый enum
|
||
|
||
`"ok": { "type": "boolean", "const": true }` → openapi-generator генерит
|
||
`enum ('true')` со СТРОКОВЫМ значением → настоящий `true` (boolean) не проходит
|
||
валидацию. Убирать `const` из схемы или не использовать его на булевых полях.
|
||
|
||
## Гоча 3 — oneOf + discriminator не десериализуется
|
||
|
||
`Schedule` (kind: cron|interval|once, oneOf + discriminator) в сгенерированной
|
||
модели приходит с `actual_instance=None` — правило (`cron`) недостижимо через
|
||
модель, только через raw JSON: `raw["schedule"]["schedule"]["cron"]`.
|
||
Касается любого oneOf+discriminator на этой версии генератора (3.1-ветка).
|
||
Фикс (round 2): плоская output-схема вместо oneOf — `kind` + опциональные
|
||
поля; per-kind валидация на сервере (parseScheduleEntry).
|
||
|
||
## Окружение (Windows)
|
||
|
||
- `openapi-generator` 7.x требует **Java 11+**: системная Java 8 падает на
|
||
class-load; ставь `JAVA_HOME` на новый JDK (Temurin 25 работает).
|
||
**Важно (round-2 уточнение):** CLI-обёртка (`@openapitools/openapi-generator-cli`)
|
||
спавнит `java` из **PATH**, не из `JAVA_HOME` — одного JAVA_HOME мало:
|
||
`$env:Path = "$env:JAVA_HOME\bin;" + $env:Path` до запуска, иначе
|
||
UnsupportedClassVersionError (class file 55.0 vs JRE 52.0 = Java 8 в PATH).
|
||
Запуск: `npx --yes @openapitools/openapi-generator-cli generate -i spec.json -g python -o out --additional-properties=packageName=...`
|
||
- Wrapped-ответы, объявленные **инлайн в `components.responses`**, дают имена
|
||
`InlineObject..InlineObjectN` и доступ `resp.schedule.schedule.cron` (двойной
|
||
`.schedule`). Работает; красивее — именованные схемы в `components.schemas`.
|
||
- `info.version` спеки НЕ бьётся вместе с версией пакета (в 0.1.2 и 0.2.0 —
|
||
`0.1.0`); версия живёт только в package.json / dist-tags.
|
||
- Windows excluded port ranges (Hyper-V/WinNAT) резервируют порты (напр. 8090/8091):
|
||
`EACCES` на listen. Проверка: `netsh interface ipv4 show excludedportrange protocol=tcp`.
|
||
- Консоль cp1251: не печатай `→`/`«»` в скриптах — `'charmap' codec can't encode`; ASCII-only.
|
||
|
||
## Паттерн обхода (потребитель, клиент не трогаем) — исторический
|
||
|
||
Тонкий raw-слой рядом с сгенерированным пакетом (в примере был `raw_ops.py`):
|
||
только операции, где спека `{}`, через `call_api`; остальное — через типизированный
|
||
клиент. Сгенерированный пакет остаётся перегенерируемым.
|
||
|
||
> **Статус:** после round 2 (0.2.0) обход не нужен — `raw_ops.py` удалён из
|
||
> примера. Паттерн остаётся escape-hatch'ем для сырых спек в других проектах.
|
||
|
||
Пример целиком: `sched/examples/admin-client-python/` (24/24 сьют, 19/19 smoke,
|
||
typed-only).
|