Поверхности API: публичный /api/v1 и админский API
Сервер отдаёт две поверхности на одном хосте:
| Поверхность | Префиксы | Аутентификация | Назначение |
|---|---|---|---|
| Публичная | /api/v1/* | нет | Read-only версионированный контракт для приложений-потребителей |
| Админская | /api/en/*, /api/settings, /api/auth | JWT админа (cookie / Bearer-токен) | Всё, что делает админка: редактирование, импорт / экспорт, статистика, вход |
Ничто под /api/v1 не меняет данные и не требует логина; ничто вне его не входит в публичный
контракт. Публичный контракт отдаётся и как OpenAPI-документ; Swagger UI,
справочник на сайте и другие способы прочитать API сравниваются в
api-tools.md.
Публичный контракт
-
Версионированный префикс. Формы ответов под
/api/v1меняются только с новым префиксом (/api/v2). Типы, на которые опираются потребители, лежат вapps/server/types/public/v1/. -
X-API-Version: 1в каждом ответе префикса, включая ошибки. -
Конверт: каждый успешный ответ — это
{ "data": ..., "meta": { ... } }— полезная нагрузка подdata(список или один объект), пагинация и счётчики подmeta, никогда не вперемешку с элементами. -
Ошибки везде под префиксом имеют форму
ErrorResT, чем бы они ни были вызваны (валидация, неизвестный маршрут, rate limit):{ "statusCode": 429, "message": "too_many_requests", "error": true } -
Языки. Заголовочные слова английские, и префикс не несёт языкового сегмента: второй исходный язык вне скоупа, поэтому
/api/v1/words/runне станет/api/v1/en/words/run. Язык перевода — это фильтр ответа, и он передаётся многозначным query-параметромlanguage(/api/v1/words/run/translations?language=ru); так же работаетtranslation_languagesдетального поиска (повторяющийся query-ключ). Без параметра возвращаются все языки.GET /api/v1/metaперечисляет языки, которые отдаёт экземпляр, вavailable_languages; потребителям стоит читать его, а не предполагатьru. -
Rate limit. Один бюджет на IP клиента на весь префикс,
PUBLIC_API_RATE_LIMIT(<запросов>/<секунд>, по умолчанию100/60); каждый запрос стоит одну единицу, пакетный поиск включительно. Превышение отвечает429с ошибкой выше. API-ключей пока нет; если нужны квоты на клиента, поставьте экземпляр за reverse proxy. -
Кэшируемость. Каждый успешный
GETнесётETag,Last-ModifiedиCache-Control: public, max-age=<PUBLIC_API_CACHE_MAX_AGE>; условные запросы отвечают304— см. Кэширование. Именно поэтому у поиска есть формаGET.
Эндпоинты
Каждый успешный ответ — конверт: полезная нагрузка под data, пагинация и счётчики под
meta. Типы ответов — в apps/server/types/public/v1/index.ts.
| Метод | Путь | Query / тело | Ответ |
|---|---|---|---|
GET | /api/v1/meta | — | { data: { api_version, app_version, dataset_version, license, notice, counts, available_languages } } |
GET | /api/v1/openapi.json | — | OpenAPI 3-документ этого контракта (без конверта; см. OpenAPI-документ) |
GET | /api/v1/search | search, type?, limit? | { data: PublicSearchWordV1T[], meta: { count, fuzzy, short_term } } |
GET | /api/v1/search/detailed | search, type?, limit?, page?, with_meanings?, with_translations?, translation_languages? | { data: PublicWordV1T[], meta: { page, limit, has_more, fuzzy, short_term } } |
GET | /api/v1/words/{word} | — | { data: PublicWordV1T[], meta: { word, count } } |
GET | /api/v1/words/{word}/meanings | — | { data: PublicMeaningV1T[], meta: { word, count } } |
GET | /api/v1/words/{word}/translations | language? | { data: { short_translations, meaning_translations }, meta } |
GET | /api/v1/words/{word}/forms | — | { data: PublicWordFormV1T[], meta: { word, count } } |
GET | /api/v1/words/{word}/synonyms | — | { data: PublicWordLinkV1T[], meta: { word, count } } |
GET | /api/v1/words/{word}/antonyms | — | { data: PublicWordLinkV1T[], meta: { word, count } } |
GET | /api/v1/words/id/{id} | — | { data: PublicWordV1T } |
GET | /api/v1/words | фильтры, cursor?, limit?, with_meanings?, with_translations? | { data: PublicWordV1T[], meta: { limit, has_more, next_cursor } } |
GET | /api/v1/random | фильтры | { data: PublicWordV1T } |
POST | /api/v1/words/batch | { words: string[] } (1–50) | { data: { word, count, entries: PublicWordV1T[] }[], meta: { count, not_found } } |
POST | /api/v1/suggestions | { headword, word_id?, message?, kind?, edits? } | 201 { data: { id, status } } |
Те же эндпоинты можно попробовать в плейграунде сайта и на страницах Documentation в админке (api-tools.md); машиночитаемый контракт — OpenAPI-документ.
POST /api/v1/suggestions — единственная запись публичной поверхности: читатель
страниц слов на сайте отправляет отзыв в очередь модерации экземпляра (страница Suggestions
в админке). Эндпоинт делят два вида:
kind: "report"(по умолчанию) — свободный текстmessageо заголовочном слове;word_idпо желанию называет запись из ответа о слове.kind: "edit"— читатель правит форму слова целиком; предложение, которое админ может применить одним кликом, передаётся какedits, список элементов{ target_type, target_id, changes }, покрывающих каждую затронутую часть:target_type—word|meaning|meaning_translation|short_translation,target_idберётся из ответов о слове,changesсодержит предлагаемые значения полей (description/transcriptionдля слова,title/definitionдля значения или его перевода,descriptionдля краткого перевода). Сервер в момент отправки снимает текущие значения в diff «до / после»; неизвестные поля, пустые значения, цели другого заголовочного слова и предложение, ничего не меняющее, отклоняются. Применение проводит каждый элемент через те же сервисы редактирования, что и админка, поэтому изменения попадают в аудит и помечают записьuser_modified.
Заголовочное слово должно существовать в словаре. У эндпоинта собственный rate limit —
SUGGESTIONS_RATE_LIMIT, по умолчанию 5/3600 (пять сообщений в час с клиента), отдельный от
общего бюджета /api/v1, — и он отвечает 503 suggestion_queue_full, когда админа ждут 500
сообщений. См. data.md. Предложение, которое админ применил,
становится частью данных словаря и распространяется вместе с ними под лицензией данных, CC BY
4.0 (DATA_LICENSE.md); страницы слов говорят об этом рядом с формой.
В примерах localhost:3010 — порт запуска без Docker; установка через docker compose по
умолчанию публикует API на localhost:3240 (SERVER_PORT).
curl 'http://localhost:3010/api/v1/search?search=run&limit=5'
curl 'http://localhost:3010/api/v1/search/detailed?search=run&with_meanings=true'
curl 'http://localhost:3010/api/v1/words/run'
curl 'http://localhost:3010/api/v1/words?part_of_speech=noun&word_level=B1&word_level=B2&limit=50'
curl 'http://localhost:3010/api/v1/random?part_of_speech=verb&word_level=A2'
Тиры поиска и допуск опечаток
Оба поиска — чтения GET: поля передаются в строке запроса
(translation_languages повторяющимся ключом, булевы как true / false), ответ несёт
кэш-заголовки префикса, а сам поиск можно вставить в браузер или передать ссылкой. limit —
1–100 (по умолчанию 10) у поиска и 1–20 у детального, чей page начинается с 1.
translation_languages либо опущен (все языки), либо непустой список — пустой список отвечает
400. Формы POST из альфы убраны (Удалённые алиасы).
Оба эндпоинта поиска ранжируют ответ по тирам: точное заголовочное слово, фразовые варианты,
начинается с термина, фразы, содержащие его как слово, оканчивается на него, содержит где
угодно. Каждый тир на Postgres обслуживается индексом (btree в байтовом порядке для префиксов,
триграммный GIN для остального). Внутри тира записи «начинается с» идут в байтовом
порядке (автодополнение читает их как набрано), остальные — сначала самое короткое заголовочное
слово: language раньше body language для guag. Словоформа в каждом тире сводится к своей
базовой записи, поэтому заголовочное слово появляется один раз, сколько бы его форм ни
совпало; каждый тир заполняет оставшуюся ему часть limit разными заголовочными словами, а
type действует во всех тирах, включая точный.
Когда ни один тир не совпал, вместо них отвечает нечёткий тир: заголовочные слова, чьи
триграммы достаточно похожи на термин (pg_trgm, похожесть ≥ 0.3), лучшее совпадение первым.
Такой ответ несёт meta.fuzzy: true и similarity (0–1) у каждого элемента — сигнал «возможно,
вы имели в виду» для UI или SDK:
{ "data": [{ "word": "relieve", "similarity": 0.45, "…": "…" }], "meta": { "count": 8, "fuzzy": true } }
Примечание
fuzzy равен false, когда точные тиры что-то нашли, и когда ничего похожего нет вовсе
(пустой data). Нечёткий тир есть только на экземплярах с Postgres (pg_trgm); экземпляр на
SQLite на опечатку отвечает пустым списком с fuzzy: false.
Термин из одного-двух символов (после обрезки пробелов) ищется только в точном и префиксном
тирах — само заголовочное слово, его фразовые варианты, базовая запись словоформы и
заголовочные слова, начинающиеся с термина, — и отвечает с meta.short_term: true. Тиры по
суффиксу, подстроке, фразам и нечёткий пропускаются: любую букву содержит половина словаря,
так что эти тиры вернули бы произвольный срез, а не совпадение, и триграммному индексу нечего
искать при менее чем трёх символах. a, I, ok, TV по-прежнему отвечают как точные
заголовочные слова; пустой термин отвечает пустым списком.
Чтение по заголовочному слову
GET /api/v1/words/{word} отвечает каждой записью заголовочного слова — по элементу на
часть речи, каждая с формами, значениями (определения, примеры, переводы, синонимы, антонимы)
и краткими переводами. Написание сравнивается без учёта регистра; пробелы во фразах кодируйте
в URL (/api/v1/words/put%20up%20with). Словоформа приводится к базовой записи:
/api/v1/words/ran отвечает глаголом run (с ran среди его forms). Неизвестное
написание отвечает 404 с word_doesnt_found.
Частичные чтения (/meanings, /translations, /forms) раскладывают те же записи в один
список; каждый элемент несёт word_id и part_of_speech, чтобы его можно было привязать к
записи. /translations делится на short_translations (по записи) и meaning_translations
(по значению, с meaning_id); ?language=ru оставляет один язык. /synonyms и /antonyms перечисляют связанные заголовочные слова каждого значения —
{ word, meaning_id, word_id, part_of_speech }, каждое word читается через /words/{word}, —
так что тезаурусу не нужна полная запись; слово без связей отвечает пустым списком.
GET /api/v1/words/id/{id} — та же запись по её числовому id (id любого элемента выше).
POST /api/v1/words/batch с { "words": ["run", "ran", "put up with"] } ищет до
50 написаний за один запрос — для потребителя, обогащающего список слов, который иначе
потратил бы весь бюджет rate limit на GET по одному слову. Каждое написание сравнивается как
в одиночном чтении; ответ сохраняет порядок запроса, по элементу на написание — word
(нормализованное написание), count и entries, ровно то, что GET /api/v1/words/{word}
отдаёт под data, — схлопывая дубликаты и регистр, а написания без записи перечисляет в
meta.not_found вместо ошибки. Пакет считается одним запросом для rate limit независимо
от размера; экземпляры, открытые в интернет, подбирают PUBLIC_API_RATE_LIMIT с учётом этого.
Будучи POST, он не несёт кэш-валидаторов; потребителю, который перечитывает одни и те же
слова, выгоднее кэшируемые одиночные чтения.
Элементы слов — явная проекция строк словаря: поля PublicWordV1T и его частей в
apps/server/types/public/v1/index.ts — всё обещание целиком, каждое присваивается по имени из
строки (src/modules/PublicApiModule/utils/projection.ts), так что колонка, добавленная в базу,
не становится публичной случайно. Редакторское состояние экземпляра — generated,
generated_by_model, version, user_modified — не входит в v1; оно остаётся в админском API
(GET /api/en/{id}), откуда его читает админка.
Список с фильтрами и курсорная пагинация
GET /api/v1/words перечисляет записи в порядке (word, id) — заголовочное слово по байтам
без учёта регистра (LOWER(word) COLLATE "C" на Postgres, LOWER(word) на SQLite:
a bag of wind раньше aaron burr, какой бы ни была локаль базы, а грамматический шаблон с
заглавной буквы — It’s the first time … — стоит среди i, а не раньше a), затем id. Фильтры: part_of_speech, word_level,
language_register, category, area_variant, form_of_word, плюс search и is_obsolete. Каждый enum-фильтр принимает одно значение или повторяющийся ключ; значения
одного фильтра объединяются через OR, разные фильтры — через AND
(?word_level=B1&word_level=B2&part_of_speech=noun — существительные B1 или B2). search
оставляет заголовочные слова, начинающиеся с префикса, без учёта регистра (?search=ru — run,
rung, runner, …; префикс фразы сохраняет пробелы); упорядоченный как список и кэшируемый как
любой GET, он и есть то, по чему стоит листать автодополнению или алфавитному указателю
вместо поиска. is_obsolete=true / false оставляет только устаревшие или только актуальные
записи. Без form_of_word перечисляются только базовые формы; словоформы доступны через
forms базовой записи или явно (?form_of_word=past_simple). Элементы не несут значений и
кратких переводов, пока не передано with_meanings=true / with_translations=true.
Страницы читаются курсором: возьмите meta.next_cursor страницы и передайте его обратно как
?cursor= (с теми же фильтрами), чтобы получить следующую; next_cursor равен null на
последней странице, а has_more говорит, есть ли она. В отличие от номеров страниц курсор
никогда не повторяет и не пропускает элемент, пока словарь редактируется. limit — от 1 до
100, по умолчанию 20.
Важно
Курсор непрозрачный — не собирайте его руками; нераспознанное значение отвечает 400 с
invalid_cursor, как и курсор, взятый со страницы, прочитанной с другими фильтрами (он несёт
их отпечаток); ?cursor= без значения — первая страница.
Случайная запись
GET /api/v1/random отвечает одной случайной записью, подходящей под те же фильтры, что и
список (базовые формы, если не задан form_of_word); 404, когда ничего не подходит. Выбор —
это поиск по индексу, а не ORDER BY random(), дёшево на словаре в 300 тысяч строк; записи
сразу после пропуска в id выпадают чуть чаще, что для «слова дня» не имеет значения.
Meta
GET /api/v1/meta описывает, что отдаёт экземпляр: api_version ("1"), app_version
(package.json сервера), dataset_version (версия датасета, из которого словарь импортирован
в последний раз, null для данных, созданных на месте или импортированных без манифеста),
условия использования данных — license (идентификатор SPDX, "CC-BY-4.0"), license_url и
attribution (строка, которую потребитель обязан показывать, см.
DATA_LICENSE.md), notice (уведомление о происхождении, которое стоит
передать читателям: данные сгенерированы языковыми моделями и не проверены людьми,
data.md), — counts (записи, слова, фразы, грамматические
шаблоны, словоформы, значения, переводы значений и краткие переводы; счётчики обновляются не
чаще раза в минуту) и available_languages: source, язык заголовочных слов
(["en"]), и translations, языки, которые может нести перевод в этой сборке (["ru", "es", "fr", "de", "pt", "zh", "ar"]), — значения, которые принимает ?language=. Они описывают схему, а не данные: язык
перечислен независимо от того, импортирован ли уже хоть один перевод на него.
OpenAPI-документ
Контракт выше — это ещё и документ OpenAPI 3, собранный из контроллеров, DTO и типов ответов
работающего кода — ничего не написано руками, поэтому расхождение невозможно.
GET /api/v1/openapi.json отдаёт его с любого экземпляра, с кэш-заголовками префикса;
apps/server/openapi/public-v1.json — закоммиченная копия, из которой генерируются SDK и сайт.
Где что используется и цепочка регенерации после изменения:
api-tools.md. Генератор
поднимает приложение без прослушивания порта, на SQLite в памяти, поэтому ему не нужен .env,
а его вывод зависит только от исходного кода; admin.json — весь API вместе с админской
поверхностью — пишется рядом и игнорируется git.
Схемы ответов. Контроллеры типизируют ответы TypeScript-контрактом в
apps/server/types/public/v1, который Swagger не видит, поэтому генератор читает эти типы
через ts-json-schema-generator, преобразует результат в компонентные схемы OpenAPI 3.0
(nullable для | null, enum'ы, дженерики раскрыты; src/openapi/json-schema-to-openapi.ts)
и коммитит их в openapi/public-v1.schemas.json. src/openapi/public-responses.ts сопоставляет
каждой публичной операции тип ответа и статусы ошибок; сборка документа падает для маршрута,
которого там нет, так что эндпоинт не может выйти нетипизированным. Работающий сервер отдаёт
закоммиченные схемы — без TypeScript во время выполнения. test/public-schemas.e2e-spec.ts
вызывает каждую операцию на заполненном словаре и строго проверяет реальные тела по отдаваемым
схемам (неизвестные поля — ошибка), что и делает схемы надёжными для генераторов SDK.
SDK
- Node.js / TypeScript —
@vocab-bloom-hub/client: метод на эндпоинт (включая пакетный поиск и тезаурус), типы, сгенерированные изopenapi/public-v1.json, типизированные ошибки, итерация по курсору и по страницам, необязательный ETag-кэш, опциональные повторы на429/5xxс учётомRetry-After, версионированныйUser-Agent. ESM и CommonJS, у каждого свой файл деклараций. В npm с первой альфы. - Python —
vocab-bloom-hub: sync и async клиенты на httpx, pydantic-модели из того же спека, типизированные исключения, опции на запрос, опциональные повторы, итерация по курсору и по страницам, ETag-кэш,words_dataframe()для ноутбуков. В PyPI с первой альфы.
Кэширование
Данные словаря меняются редко, поэтому публичные чтения GET рассчитаны на кэширование
браузерами, CDN и reverse proxy. Каждый успешный ответ GET несёт:
| Заголовок | Значение |
|---|---|
ETag | слабый, хэш JSON-тела: W/"…". Меняется ровно тогда, когда меняется ответ |
Last-Modified | самое свежее изменение где угодно в словаре (записи, слова, значения, переводы), обновляется раз в минуту |
Cache-Control | public, max-age=<PUBLIC_API_CACHE_MAX_AGE> (по умолчанию 3600); public, no-cache, когда переменная равна 0 |
HEAD отвечает теми же тремя заголовками, что и GET этого адреса, поэтому кэш, проверяющий
свежесть через HEAD, видит те валидаторы, которые сохранил. Клиент, отправляющий тег обратно,
перепроверяет ответ одним обменом без тела:
curl -i 'http://localhost:3010/api/v1/words/run' # 200, ETag: W/"…"
curl -i -H 'If-None-Match: W/"…"' 'http://localhost:3010/api/v1/words/run' # 304, без тела
curl -i -H 'If-Modified-Since: <Last-Modified>' 'http://localhost:3010/api/v1/words/run'
Инвалидация неявная: ETag — хэш содержимого, поэтому первый ответ после правки или импорта
несёт новый тег, и 304 никогда не отдаётся для изменённых данных. Last-Modified
информационный (отстаёт не больше чем на минуту) — когда отправлены оба валидатора, решает
ETag. CDN или прокси впереди держит ответ max-age, затем перепроверяет; на экземпляре,
словарь которого редактируется вживую, уменьшите PUBLIC_API_CACHE_MAX_AGE (или поставьте
0).
Примечание
Не кэшируются: два запроса POST, пакетный поиск и предложение правки (HTTP-кэши не хранят
POST), каждая ошибка под префиксом (Cache-Control: no-store, так что промах или 429
никогда не отдаются из кэша) и всё под админскими префиксами (no-store на каждом ответе,
включая 401 и 404 выключенной поверхности).
Удалённые алиасы
POST /api/en/search и POST /api/en/search/detailed — поисковые маршруты времён до
публичного API, которые всю альфу отвечали голыми телами и заголовком Deprecation: true, —
удалены с v0.2.0-beta.1, как и формы POST /api/v1/search и POST /api/v1/search/detailed,
служившие мостом в альфе (те же поля в JSON-теле). Все четыре отвечают 404, как любой
неизвестный маршрут; преемники — GET /api/v1/search и GET /api/v1/search/detailed с
конвертом { data, meta }.
Экземпляр только с публичным или только с админским API
Две переменные решают, какие поверхности отдаёт экземпляр; обе по умолчанию включены:
| Переменная | Эффект при false |
|---|---|
PUBLIC_API_ENABLED | /api/v1/* отвечает 404, как будто маршрутов нет |
ADMIN_API_ENABLED | /api/en/*, /api/settings, /api/auth отвечают 404; админка не может войти |
Внимание
Выключить обе — ошибка конфигурации, и сервер отказывается стартовать.
Демо или встроенный экземпляр работает с ADMIN_API_ENABLED=false (данные правят в другом
месте и переносят экспортом / импортом датасета, см.
offline-import.md); внутренний редакторский экземпляр, который не
должен читаться снаружи, работает с PUBLIC_API_ENABLED=false.
Пробы: /api/health и /api/ready
Два маршрута живут под /api, но не принадлежат ни одной поверхности: liveness-проба
GET /api/health (200 { status: 'ok', version }) и readiness-проба GET /api/ready
(200 { status: 'ok' } или 503 { status: 'error', reason }, пока база не отвечает
(database_unreachable), идёт остановка с завершением запросов (shutting_down), ещё идёт
автоматическая загрузка словаря при первом старте (importing, с percent и stage) или эта
загрузка упала (import_failed, с error)). Им не нужен логин, они не ограничены rate limit,
игнорируют обе переменные выше и отправляются с Cache-Control: no-store. Они не входят в
контракт /api/v1 — публичный OpenAPI-документ их не перечисляет, SDK их не оборачивают; они
для менеджеров процессов, оркестраторов и reverse proxy
(deployment/README.md).
Приватный админский API за reverse proxy
Когда один экземпляр отдаёт обе поверхности, а из интернета должен быть доступен только
словарь, откройте /api/v1 и отгородите админские префиксы (/api/en, /api/settings,
/api/auth) на прокси — списком адресов или basic auth — либо отдавайте их только из
приватной сети. Проверенные конфиги Caddy и nginx для этого, TLS, настройка TRUST_PROXY,
нужная rate limit за прокси, и другие профили экспозиции — в
deployment/reverse-proxy.md.
Задайте CORS_ORIGINS origin'ами, которым можно вызывать API из браузера; клиенты вроде
curl CORS не затрагивает.