Vocab Bloom Hub

Поверхности API: публичный /api/v1 и админский API

Сервер отдаёт две поверхности на одном хосте:

ПоверхностьПрефиксыАутентификацияНазначение
Публичная/api/v1/*нетRead-only версионированный контракт для приложений-потребителей
Админская/api/en/*, /api/settings, /api/authJWT админа (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.jsonOpenAPI 3-документ этого контракта (без конверта; см. OpenAPI-документ)
GET/api/v1/searchsearch, type?, limit?{ data: PublicSearchWordV1T[], meta: { count, fuzzy, short_term } }
GET/api/v1/search/detailedsearch, 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}/translationslanguage?{ 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_typeword | 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 отвечает глаголом runran среди его 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 с первой альфы.
  • Pythonvocab-bloom-hub: sync и async клиенты на httpx, pydantic-модели из того же спека, типизированные исключения, опции на запрос, опциональные повторы, итерация по курсору и по страницам, ETag-кэш, words_dataframe() для ноутбуков. В PyPI с первой альфы.

Кэширование

Данные словаря меняются редко, поэтому публичные чтения GET рассчитаны на кэширование браузерами, CDN и reverse proxy. Каждый успешный ответ GET несёт:

ЗаголовокЗначение
ETagслабый, хэш JSON-тела: W/"…". Меняется ровно тогда, когда меняется ответ
Last-Modifiedсамое свежее изменение где угодно в словаре (записи, слова, значения, переводы), обновляется раз в минуту
Cache-Controlpublic, 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 не затрагивает.