Поверхности API: публичный /api/v1 и админский API
Сервер отдаёт две поверхности на одном хосте:
| Поверхность | Префиксы | Аутентификация | Назначение |
|---|---|---|---|
| Публичная | /api/v1/*, /api/compat/dictionaryapi/*, /api/compat/freedictionaryapi/* | нет | Read-only контракт для приложений-потребителей |
| Админская | /api/en/*, /api/settings, /api/auth | JWT админа (cookie / Bearer-токен) | Всё, что делает админка: редактирование, импорт / экспорт, статистика, вход |
Ничто под /api/v1 не меняет данные и не требует логина. Адаптеры совместимости под
/api/compat/* тоже публичные и доступны только для чтения. Публичный контракт отдаётся и как
OpenAPI-документ; Swagger UI,
справочник на сайте и другие способы прочитать API сравниваются в
api-tools.md.
Переход с внешних API: dictionaryapi.dev и freedictionaryapi.com. Подробное описание полей адаптеров есть в английской версии документации API.
Ответы адаптеров по всем датасетам
Суффикс /datasets у пути адаптера возвращает результаты из всех установленных датасетов
без смены активного. Обычные пути адаптеров читают активный датасет; эти расширения
Vocab Bloom Hub предназначены для сравнения и инструментов интеграции.
| Метод | Путь | Query |
|---|---|---|
GET | /api/compat/dictionaryapi/v2/entries/{language}/{word}/datasets | нет |
GET | /api/compat/dictionaryapi/v1/entries/{language}/{word}/datasets | нет |
GET | /api/compat/freedictionaryapi/v1/entries/{language}/{word}/datasets | translations, pretty |
Ответ — { data, meta }. Каждая группа в data содержит условия своего датасета:
dataset, title, active, source, dataset_version, атрибуцию, уведомления, текст
лицензии и происхождение, а также два поля:
| Поле | dictionaryapi.dev v1/v2 | freedictionaryapi.com v1 |
|---|---|---|
status | 200, если слово найдено, иначе 404 | 200, включая отсутствие слова |
response | Полный массив v1/v2 или ошибка { title, message, resolution } | Полный объект { word, entries, source, vocabBloom }, где entries может быть пустым |
В meta — запрошенные word, language, число групп datasets и found: сколько
датасетов нашли записи. HTTP-статус всего запроса — 200 даже при found: 0. Пустые
группы сохраняются. Сбой чтения базы завершает весь запрос ошибкой, а не выдаётся за отсутствие слова.
Каждый датасет читается по правилам обычного адаптера и через тот же преобразователь ответа.
dictionaryapi.dev поддерживает en, en_US, en_GB без учёта регистра кода и нативные
правила поиска заголовочного слова и словоформ. Другой язык даёт общую ошибку 404, query — 400.
freedictionaryapi.com ищет точное написание с учётом регистра, разрешает словоформы и
альтернативные написания на один переход. en и all читают английский; другой язык даёт
пустые группы. translations и pretty принимают только строчные true/false, по
умолчанию оба false; при повторе используется первое значение. pretty форматирует всю
обёртку. Оба адаптера отклоняют dataset: читаются все установленные датасеты, включая
собственные и форки. На SQLite возвращается только default.
В response сохраняются источники слов, признак изменения и полные условия в vocabBloom.
Поле active указывает действительно активный датасет. Для пустых ответов freedictionaryapi.com
резервные ссылки на источник/условия ведут на маршрут по всем датасетам, поскольку
/api/v1/meta описывает только активный. Сохраняйте условия каждой использованной группы.
Запрос занимает одну единицу общего лимита публичного API. Действуют PUBLIC_API_ENABLED,
анонимный CORS и формат ошибок соответствующего адаптера. GET/HEAD поддерживают ETag и
Last-Modified по всем датасетам, включая правки неактивного, изменения реестра и удаление.
У компактного и форматированного JSON разные ETag. Внешние сервисы не вызываются, записи
разных датасетов не объединяются. Маршруты описаны в OpenAPI, справочнике API и Playground
сайта; оба SDK содержат сгенерированные типы без отдельных методов нативного клиента.
curl -fsS 'http://localhost:3240/api/compat/dictionaryapi/v2/entries/en/hello/datasets'
curl -fsS 'http://localhost:3240/api/compat/freedictionaryapi/v1/entries/en/hello/datasets?translations=true'
Публичный контракт
-
Версионированный префикс. Формы ответов под
/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. Единственное исключение — собственный сайт экземпляра: его серверные запросы несутINTERNAL_API_TOKENвX-Internal-Tokenи не считаются (environment.md). -
Кэшируемость. Каждый успешный
GETнесётETag,Last-ModifiedиCache-Control: public, max-age=<PUBLIC_API_CACHE_MAX_AGE>, stale-while-revalidate=<то же>; условные запросы отвечают304— см. Кэширование. Именно поэтому у поиска есть формаGET.
Эндпоинты
Каждый успешный ответ — конверт: полезная нагрузка под data, пагинация и счётчики под
meta. Типы ответов — в apps/server/types/public/v1/index.ts.
| Метод | Путь | Query / тело | Ответ |
|---|---|---|---|
GET | /api/v1/meta | — | { data: { api_version, app_version, dataset, source, dataset_version, license, …, counts, available_languages } } |
GET | /api/v1/openapi.json | — | OpenAPI 3-документ этого контракта (без конверта; см. OpenAPI-документ) |
GET | /api/v1/search | search, dataset?, type?, limit? | { data: PublicSearchWordV1T[], meta: { count, fuzzy, short_term } } |
GET | /api/v1/search/detailed | search, dataset?, 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/{word}/history | — | { data: PublicChangeV1T[], meta: { word, count } } |
GET | /api/v1/words/{word}/datasets | — | { data: PublicWordDatasetV1T[], meta: { word, datasets, found } } |
GET | /api/v1/words/{word}/datasets/{dataset}/history | — | { data: PublicChangeV1T[], 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, dataset?, word_id?, message?, kind?, edits?, author_name?, author_consent? } | 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.
Отправитель, который хочет быть указан автором, передаёт author_name (до 80 символов)
вместе с author_consent: true: имя публикуется вместе с исправлением — на странице
слова, в /words/{word}/history, в экспортах данных. Имя без согласия отвечает 400;
согласие без имени никого не называет. По просьбе владелец удаляет имя
(data.md).
Заголовочное слово должно существовать в словаре. У эндпоинта собственный 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'
Поиск в выбранных датасетах (#652)
GET /api/v1/search и /search/detailed принимают необязательный dataset=<имя>.
Без него ответ и поведение остаются прежними: поиск в активном словаре. Явный выбор
добавляет meta.dataset и не переключает активный датасет. Закрытый, удалённый или
неизвестный датасет отвечает одинаковым 404 dataset_not_found, без подмены активным.
Ошибка чтения выбранного словаря — 503 dataset_unavailable.
GET /api/v1/datasets возвращает публичные датасеты с их условиями в порядке установки,
без счётчиков слов и открытия соединений со словарями. meta.max_search_datasets — 5.
GET /api/v1/search/datasets?search=run&datasets=default&datasets=wiktionary ищет в 1–5
уникальных публичных датасетах. Группы идут в порядке установки; каждая содержит название,
источник, версию, лицензию и атрибуцию. Одинаковое написание в разных словарях не объединяется.
status: "ok":dataиmetaподробного поиска; пустойdataозначает отсутствие совпадений.status: "unavailable":error: "dataset_unavailable", без результатов и пагинации. Частичная ошибка — HTTP 200 сmeta.partial: true; недоступный по публичности адресат отклоняет весь запрос с 404. Внутренние сведения об ошибке не возвращаются.- Фильтры, ранжирование, идентификаторы и пагинация действуют внутри каждой группы:
limit1–20 (по умолчанию 10),page1–20 (по умолчанию 1), не более 100 записей в ответе. Чтобы перелистнуть одну группу, запросите только её имя и следующую страницу. - Каждый выбранный датасет расходует одну единицу общего публичного лимита, включая неудачные чтения. Бюджет списывается до поиска; при нехватке — 429 без выполнения поиска, уже списанные единицы не возвращаются. Группы читаются последовательно.
- Выбранный поиск и список датасетов требуют проверки ETag при каждом использовании
(
public, no-cache, must-revalidate).Last-Modifiedинформационный: изменения видимости могут произойти в одну секунду. Частичные ошибки —no-store, никогда 304.
На сайте раздел «Словари» позволяет выбрать до пяти словарей; без выбора используется активный.
Запрос сохраняется в q, один выбор — в dataset, несколько — в повторяющемся datasets,
включая смену языка интерфейса. Ссылки ведут на вкладку словаря-источника, например
/ru/word/Amber?dataset=my_words. Неудачные группы показаны отдельно от пустых.
Тиры поиска и допуск опечаток
Оба поиска — чтения 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}, —
так что тезаурусу не нужна полная запись; слово без связей отвечает пустым списком.
Написания, которые различаются регистром
Собственный датасет проекта записывает заголовочные слова строчными, и поиск не зависит от
регистра: Run, RUN и run отвечают одними и теми же записями с meta.word: "run". В
датасете публичного источника есть слова, которые больше ничем не различаются: Polish, язык,
и polish, полировать; Test, матч по крикету, и test. Это отдельные слова:
| Запрос | Ответ | meta.word | meta.variants |
|---|---|---|---|
/words/polish | записи с написанием polish | polish | ["Polish"] |
/words/Polish | записи с написанием Polish | Polish | ["polish"] |
/words/POLISH | записи обоих: слова с таким написанием в словаре нет | polish | ["Polish", "polish"] |
/words/Run, /RUN | записи run: в словаре одно написание | run | [] |
meta.variants перечисляет другие написания слова, каждое читается через /words/{word}, а
каждая запись называет своё в поле word. Частичные чтения, история и пакетный запрос
сопоставляют написание так же. Поле variants появилось после 1.0 и в контракте необязательно.
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}), откуда его читает админка.
Каждое слово называет свой source — откуда взяты данные записи: vocab-bloom-hub для
собственного датасета проекта, wiktionary, wordnet, princeton-wordnet, opengloss или имя
собственного датасета. Это идентификатор отдающего датасета, а не вся история происхождения слова.
Экземпляр отдаёт один датасет за раз (datasets.md), поэтому
у всех слов ответа источник один; условия использования, которые к нему относятся, — в
GET /api/v1/meta. Единственное чтение, которое отвечает из нескольких датасетов,
/words/{word}/datasets, группирует записи по датасетам и называет
условия каждой группы. Поле появилось после 1.0 и в контракте необязательно: сервер 1.0 его не
присылает.
source есть в каждом ответе, который несёт запись, её часть или правку записи: в чтении
по слову, по id, пакетном, в списке и случайной записи, в обоих поисках, в элементах
/meanings, /forms, /translations, /synonyms и /antonyms, в правках /history и в
записях каждой группы /words/{word}/datasets. Элемент, взятый отдельно от ответа, по-прежнему
говорит, чему он атрибутирован.
Каждое слово сообщает, было ли оно изменено (modified): изменено или добавлено на этом
экземпляре, то есть это не то, или не только то, что говорит источник. Лицензии датасетов
требуют сообщать об этом читателю; показывайте отметку везде, где показываете запись. Отметка
относится к записи — существительное может быть изменено, а глагол того же слова нет — и
возвращается в false, когда обновление датасета заменяет запись содержимым источника или
когда правку откатывают. В контракте поле необязательно, как и source.
Отметка есть в каждом ответе, который несёт запись или её часть: в чтении по слову, по id,
пакетном, в списке и случайной записи, в обоих поисках и в частичных чтениях — элемент
/meanings, /forms, /translations, /synonyms и /antonyms несёт modified записи, к
которой он относится, рядом с word_id и part_of_speech. GET /api/v1/meta сообщает то же
о датасете в целом: modified_entries.
История слова
GET /api/v1/words/{word}/history перечисляет, что именно изменено: правки записей слова,
которые видны в отдаваемых данных, от новых к старым. История датасета начинается пустой:
данные считаются такими, какими их опубликовал источник, пока не записана правка. Поэтому для
слова, которое отдаётся как в источнике, ответ пустой; для неизвестного написания — 404; словоформа отвечает историей
своей базовой записи.
{
"data": [
{
"created_at": "2026-09-27T10:00:00.000Z",
"word": "lamp",
"part_of_speech": "noun",
"entity": "meaning",
"action": "update",
"record": { "title": "a light", "sort_order": 1 },
"diff": { "definition": { "before": "A light.", "after": "A device that gives light." } },
"origin": "suggestion",
"author": "Ada Lovelace",
"source": "wiktionary"
}
],
"meta": { "word": "lamp", "count": 1 }
}
| Поле | Что означает |
|---|---|
entity | что правили: word, word_form, meaning, meaning_translation, short_translation |
action | create, update или delete |
word | написание записи, к которой относится правка, как его называет поле word записи: вместе с part_of_speech оно указывает запись среди тех, что отдаёт /words/{word} |
part_of_speech | запись слова, к которой относится правка |
record | что называет изменённую часть внутри записи — форму, заголовок значения, перевод; null для самой записи. Без идентификаторов |
diff | { поле: { before, after } }: изменившиеся поля; все поля добавленной (before: null) или удалённой (after: null) части |
origin | admin — правка владельца; suggestion — исправление читателя, применённое владельцем |
author | читатель, приславший исправление, если он просил себя указать; иначе null |
source | источник датасета, в котором сделана правка: чему атрибутирована изменённая запись, наряду с владельцем экземпляра |
Значения — то, что говорят сами записи: связанные слова названы написанием, без
идентификаторов и временных меток базы, поэтому та же история читается одинаково на другом
экземпляре. Редакционное состояние экземпляра (generated, generated_by_model) в историю не
попадает. Отдаётся не больше 200 правок, самые новые. Как история ведётся и как откатывается
правка: datasets.md.
Слово во всех датасетах
Экземпляр может держать несколько датасетов, а отдаёт один. GET /api/v1/words/{word}/datasets
читает слово из каждого датасета экземпляра: для потребителя, который сравнивает, что
говорят источники (запись датасета проекта рядом с записью Wiktionary или WordNet), и владельцу
ничего не нужно переключать. Ответ состоит из групп, по одной на датасет, в порядке установки
датасетов, и группы никогда не объединяются:
{
"data": [
{
"dataset": "default",
"active": false,
"source": "vocab-bloom-hub",
"dataset_version": "1.0.0",
"license": "CC-BY-4.0",
"license_url": "https://creativecommons.org/licenses/by/4.0/",
"attribution": "…",
"attribution_url": "…",
"notice": "…",
"license_text": "",
"word": "polish",
"variants": [],
"count": 2,
"entries": [{ "id": 17, "word": "polish", "source": "vocab-bloom-hub", "modified": false, "…": "…" }]
},
{
"dataset": "wiktionary",
"active": true,
"source": "wiktionary",
"dataset_version": null,
"license": "CC-BY-SA-4.0",
"license_url": "https://creativecommons.org/licenses/by-sa/4.0/",
"attribution": "…",
"attribution_url": "https://en.wiktionary.org",
"notice": "",
"license_text": "",
"word": "polish",
"variants": ["Polish"],
"count": 3,
"entries": [{ "id": 90412, "word": "polish", "source": "wiktionary", "modified": true, "…": "…" }]
}
],
"meta": { "word": "polish", "datasets": 2, "found": 2 }
}
| Поле | Что оно говорит |
|---|---|
dataset | имя датасета на экземпляре: default, wiktionary, wordnet, wordnet_princeton, opengloss или имя собственного датасета/форка |
active | тот ли это датасет, который отдают остальные маршруты API |
source, dataset_version, license, license_url, attribution, attribution_url, notice, license_text | условия этого датасета, те же, что /meta называет для отдаваемого |
word, variants | слово, которому принадлежат записи в этом датасете, и другие его написания там (по правилу чтения слова); запрошенное написание строчными и [] для датасета без такого слова |
count, entries | то, что GET /words/{word} ответил бы в data, будь этот датасет отдаваемым; 0 и [] для датасета без такого слова |
meta.word | написание, которое было запрошено |
meta.datasets, meta.found | сколько датасетов держит экземпляр и в скольких из них слово есть |
- Написание сопоставляется внутри каждого датасета.
polishназывает одно слово в датасете, который пишет слова строчными, и одно из двух в датасете, где есть ещё иPolish, поэтому у каждой группы своиwordиvariants. - Запись говорит то, что о ней говорит её датасет:
sourceназывает источник группы, аmodifiedсообщает, изменена ли запись на экземпляре в этом датасете. Что именно изменено, отдаётGET /api/v1/words/{word}/datasets/{dataset}/history: ответ истории слова, прочитанный из названного датасета;404 dataset_not_foundдля датасета, которого на экземпляре нет. - Id свои у каждого датасета:
idзаписи имеет смысл только в её группе./words/id/{id}читает отдаваемый датасет. - Условия свои у каждой группы. Запись используется на условиях группы, из которой взята, и потребитель, взявший записи из нескольких групп, связан условиями каждой: лицензия share-alike одной группы не распространяется на записи другой и не покидает свои.
404 word_doesnt_found, когда слова нет ни в одном датасете. Датасет без такого слова отвечает пустой группой: то, что в источнике слова нет, тоже сведение.- Один запрос в счёт лимита для этого чтения слова, сколько бы ни было датасетов. Групповой поиск отдельно расходует единицу бюджета за каждый выбранный датасет.
- Читаются только публично открытые датасеты, активные и неактивные, с историей правок и указанными авторами. Новые установки и собственные датасеты изначально закрыты.
- Сообщение об ошибке указывает просматриваемый датасет. Передавайте
datasetгруппы вPOST /api/v1/suggestions: все IDs проверяются в этом датасете и для указанного слова. Безdatasetиспользуется активный словарь. Закрытый, неизвестный или удалённый датасет возвращает404 dataset_not_found, без подмены цели. Лимит запросов клиента общий для всех датасетов, ограничение очереди — отдельное для каждого. После закрытия датасета администратор по-прежнему может проверить и применить полученные исправления.
Экземпляр с одним датасетом, то есть любой экземпляр на SQLite, отвечает одной группой.
Что с этим делает потребитель
Отметка и история существуют потому, что лицензии данных требуют сообщать об изменениях, и эта обязанность переходит к тому, кто показывает данные дальше:
| Вы читаете | Вы делаете |
|---|---|
attribution, license_url из /meta | показываете строку атрибуции и ссылку на лицензию везде, где показываете данные |
modified: true у слова или его части | сообщаете рядом с записью, что она изменена: это не то, или не только то, что опубликовал источник |
/words/{word}/history | по желанию: показываете, что изменено, и указываете автора исправления (author), если он назван |
группу из /words/{word}/datasets | делаете всё перечисленное для каждой группы, на условиях этой группы: записи двух групп показываются с двумя атрибуциями |
license из /meta с окончанием -SA-4.0 | сохраняете под той же лицензией то, что построено на данных, включая изменённые записи |
origins, contributions, licenses слова | сохраняете атрибуцию, уведомления и связанные лицензии каждого источника и вклада; основная лицензия датасета их не заменяет |
непустой license_text из /meta | держите этот текст при каждой копии данных: показываете его или ссылку на страницу с ним |
modified_entries из /meta больше 0 | знаете, что экземпляр отдаёт данные, отличающиеся от источника |
Читайте эти значения из API, а не зашивайте в код: владелец в любой момент может активировать
другой датасет, изменить запись или откатить правку. Условия полностью:
DATA_LICENSE.md.
Список с фильтрами и курсорная пагинация
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 (его имя на экземпляре, default у того, с
которым экземпляр появился), source (откуда его данные: vocab-bloom-hub, wiktionary,
wordnet, princeton-wordnet, opengloss или имя собственного датасета) и dataset_version (версия датасета, из которого словарь импортирован
в последний раз, либо версия собственного датасета или форка, заданная владельцем;
null, если версия неизвестна; это
строка для показа, формат не обещан: 1.0.0 у датасета проекта, день выгрузки у Wiktionary,
2026.09.25, редакция у WordNet, 2025, или 2.4 у OpenGloss:
datasets.md), —
условия использования данных — license (идентификатор SPDX, "CC-BY-4.0" у датасета
проекта, "CC-BY-SA-4.0" у Wiktionary), license_url, attribution (строка, которую
потребитель обязан показывать, см. DATA_LICENSE.md) и
attribution_url (куда ведёт эта строка, null, когда датасет ссылку не называет), notice
(уведомление о происхождении, которое стоит передать читателям, — у датасета проекта: данные
сгенерированы языковыми моделями и не проверены людьми, data.md; пустая строка,
когда у датасета его нет), — counts (записи, слова, фразы, грамматические
шаблоны, словоформы, значения, переводы значений и краткие переводы; счётчики обновляются не
чаще раза в минуту) и available_languages: source, язык заголовочных слов
(["en"]), и translations, языки, которые может нести перевод в этой сборке (["ru", "es", "fr", "de", "pt", "zh", "ar"]), — значения, которые принимает ?language=. Они описывают схему, а не данные: язык
перечислен независимо от того, импортирован ли уже хоть один перевод на него.
Условия относятся к активному датасету и меняются, когда владелец активирует другой
(datasets.md): читайте их из meta, а не зашивайте в код лицензию датасета
проекта. license_text содержит полный текст уведомлений, которые источник датасета требует
хранить вместе с данными, — лицензию WordNet, лицензию CMU Pronouncing Dictionary; пустая
строка у датасета, лицензия которого называется одной ссылкой. Показывайте этот текст или
ссылку на страницу с ним везде, где лицензия требует свой текст на каждой копии. dataset,
source, attribution_url и license_text появились после 1.0 и в контракте необязательны.
modified_entries считает заголовочные слова, которые экземпляр отдаёт с записями, изменёнными
или добавленными на нём: 0 означает, что данные такие, какими их опубликовал источник, любое
другое число означает, что потребитель показывает данные, отличающиеся от источника, и должен
об этом сообщать (История слова). Значение обновляется вместе со счётчиками,
не чаще раза в минуту. В контракте поле необязательно.
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>, stale-while-revalidate=<то же> (по умолчанию 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 не затрагивает.
Источники слова и связанные лицензии
Ответы со словами, результатами поиска, формами и отдельными частями слова могут содержать
необязательные origins[], contributions[] и licenses[]. Источники относятся к базовому
слову и части речи. Каждый источник хранит название, версию (или null), ссылки url и
record_url, атрибуцию, обязательные уведомления и лицензии: идентификатор, если известен,
название, URL и полный текст, когда он требуется. origin_id связывает лицензию с источником
или вкладом; сохраняйте эту связь при показе атрибуции.
contributions содержит условия тех содержательных правок, которые ещё действуют для слова.
Неизменённая копия или слово в форке сохраняет исходные источники без добавления лицензии
принимающего датасета. Реальная правка добавляет снимок условий вклада и его лицензию; откат
всех таких правок убирает вклад из текущего ответа. Смена названия, версии или лицензии
датасета не переписывает ранее сохранённые снимки.
scope: "dataset" означает, что источник не уточнил распределение материала по словам.
method: "manual" обозначает сведения, внесённые пользователем, а "dataset" — условия,
сохранённые из датасета. Дата recorded_at может быть неизвестна (null). Повторные копирования
и форки записываются в acquisitions[]: method ("copy" или "fork"), дата и ревизия.
Поле via может назвать промежуточный датасет, не добавляя его лицензию к неизменённому слову.
inherited защищает унаследованные условия от обычного редактирования.
license_relation: "all" означает совместное применение условий, "any" — варианты,
явно предложенные источником. Сам список или произвольная лицензия не гарантируют совместимость.
Группы датасетов и /meta передают источники и описание; основная license остаётся лицензией
по умолчанию и лицензией вклада датасета. source обозначает отдающий датасет. История может
содержать inherited_from, причину исправления сведений reason и снимок contribution.
У каждого слова OpenGloss сохраняется его источник; у слов с отметкой происхождения из WordNet
добавляются источник Princeton WordNet 3.0 и его лицензия. Одного source: "opengloss" или
license из /meta недостаточно, чтобы определить все условия конкретного слова.
Поля необязательны для совместимости со старыми серверами v1 и включены в модели обоих SDK. Редактирование описано в руководстве по датасетам, перенос и совместимость — в руководстве по импорту.