В начале недели я рассказывал тут про ядро своего генератора QR-кодов листами: детерминированная генерация, golden-тесты, пять граблей. Это продолжение про часть, которая доехала до прода через два дня после той статьи: публичный API. Делался он под сценарий, с которым приходят пользователи: они каждую неделю перепечатывают ценники и этикетки из учетной системы, и ходить для этого руками на сайт им надоедает.
Теперь API есть. POST https://qqkod.ru/api/v1/sheets принимает JSON со списком до 1500 кодов и параметрами листа, в ответ сразу приходит файл: PDF с листами A4, SVG полистно или PNG поштучно в архиве. Без регистрации и ключей. Ниже решения, которые пришлось принять, чтобы открытый на весь интернет эндпоинт генерации файлов жил спокойно. И грабля, на которой POST молча превращался в GET.
Почему без ключей
Сервис бесплатный, API тоже. API-ключ тянет за собой регистрацию, кабинет, восстановление пароля и базу почт, то есть отдельный продукт, который надо писать и поддерживать. Защита от злоупотреблений при этом все равно строится на лимитах: выдать ключ анониму так же просто, как принять анонимный запрос, сам по себе он никого не останавливает.
Поэтому доступ анонимный, а от перегрузки защищают лимиты по IP: 10 запросов в минуту и 300 в сутки. Место под ключи при этом оставлено. Лимитер принимает произвольную строку («ip:203.0.113.7» сейчас, «key:abc» потом) и про IP ничего не знает, так что когда появятся тарифы, переписывать его не придется.
Один валидатор на форму и API
Тело запроса повторяет форму на сайте поле в поле: lines массивом или строкой, тип содержимого, размер кода, поля листа, цвета, формат вывода. Валидатор общий с формой, буквально тот же класс, поэтому правила не расходятся: 1500 строк, строка до 2048 символов, проверка контраста цветов, тексты ошибок на русском. В ошибке приходит и hint, тот же диагноз, что форма показывает под полем («похоже, вы вставили CSV с разделителем»), плюс номера кривых строк.
Ответ синхронный, файлом, без очереди и поллинга. 200 кодов собираются в PDF за 1,8 секунды, очереди тут нечего делать. В заголовках ответа X-QQkod-Total-Sheets, X-QQkod-Grid, X-QQkod-Per-Sheet и X-QQkod-Items, по ним видна раскладка тиража без разбора файла.
Файл из API совпадает с файлом из браузера байт в байт при одинаковых параметрах. Отдельно я для этого ничего не писал: детерминированное ядро из прошлой статьи дает это само, одинаковый вход дает одинаковые байты, откуда бы запрос ни пришел.
Лимитер на файлах с flock
Redis ради двух счетчиков я заводить не стал: сервис живет на одной VPS, и лишняя зависимость дороже самой задачи. На каждый ключ свой JSON-файл, внутри номер минутного окна, счетчик минуты, дата по UTC и счетчик суток.
Окна фиксированные, не скользящие: минута и сутки. Скользящее окно честнее размазывает нагрузку, но требует хранить историю запросов, а фиксированное хранит 4 числа. И «10 в минуту» пользователю объяснить проще, чем «10 за любые 60 секунд».
Инкремент атомарный: fopen в режиме c+, flock с LOCK_EX, прочитать, увеличить, ftruncate, записать. Отказанный запрос тоже увеличивает счетчик, иначе запросы сверх лимита бесплатны и долбить можно без последствий. В 429 отдается Retry-After до конца ближайшего окна и X-RateLimit-Remaining.
Главное решение: fail-open. Не открылся файл, не взялся lock, в файле битый JSON, значит запрос пропускается и окна начинаются заново. Генерация не должна лечь из-за счетчика. Если лимитер ошибется в эту сторону, я получу немного лишних запросов. Если в обратную, сервис откажет живым пользователям из-за какого-нибудь сломанного права на каталог.
Уборка без крона: примерно на каждом 50-м запросе удаляются файлы состояния старше двух суток. Выглядит несерьезно, но каталог не разрастается, и отдельная задача в кроне не нужна.
Мелочь про IP, на которой спотыкаются чаще, чем на окнах. За фронтовым nginx в REMOTE_ADDR приходит 127.0.0.1, настоящий адрес лежит в X-Real-IP. Читать X-Real-IP можно только когда REMOTE_ADDR локальный, иначе кто угодно подделает заголовок и обойдет лимит.
Маршрут в Битриксе без правок сервера
Сайт живет на Битриксе, и хотелось чистый URL без правок nginx и Apache: деплой у меня это git pull на сервере, руками туда лучше не лазить. Помог штатный urlrewrite: правило с условием ^/api/v1/ ведет на local/php_interface/api/v1.php. По исходникам bitrix/modules/main/include/urlrewrite.php такой файл подключается без пролога, без ядра и шаблона. Скрипт бутстрапится сам, как обычные ajax-эндпоинты, и отдает ответ без обвязки страницы.
Грабля: 301 молча превращает POST в GET
Смоук на проде: на POST /api/v1/sheets приходит 301 Moved Permanently на адрес со слэшем. Клиенты по 301 повторяют запрос методом GET, так себя ведут браузеры и почти все HTTP-библиотеки. Тело пропадает, и вместо PDF приходит страница.
Источник нашелся в серверном .htaccess: старый блок ЧПУ-канонизации «добавляем слэш всем путям без расширения». Для страниц сайта он полезен, склеивает дубли, а у API чистый URL без расширения, и канонизация исправно ловила каждый запрос. Лечится исключением: RewriteCond %{REQUEST_URI} !^/api/ перед правилом слэша.
Запомнил на будущее: канонизация слэшей бьет по любому POST-эндпоинту с чистым URL. Добавляете такой эндпоинт, первым делом проверьте POST без слэша.
Чего в API сознательно нет
CORS-заголовков нет. API рассчитан на вызовы с сервера, из 1С или скрипта. Сценария «дергать наш API из чужой браузерной страницы» я пока не видел, а открыть CORS всегда успею.
CSRF-защиты тоже нет, у API ни сессий, ни кук, подделывать нечего (у формы на сайте токен есть, там сессия).
И файлы я не храню. Временный файл удаляется сразу после отдачи ответа, содержимое кодов в логи не пишется. В телеметрии остаются режим, размер пачки, длительность и код ошибки.
Тесты и документация
На API я написал 46 тестов: парсер запроса, лимитер с окнами и fail-open, отдача файла, эндпоинт целиком по всем веткам ошибок. Всего в проекте теперь 711.
Описание параметров, примеры curl и спека OpenAPI: qqkod.ru/api/. Лимиты общие для всех. Если под вашу задачу их мало, напишите, обсудим.
Из спорного осталось одно решение, fail-open. Если файлы лимитера побьются совсем, API какое-то время поработает без лимитов. Меня это устраивает больше, чем зеркальный вариант, где сломанный счетчик кладет генерацию. Интересно, кто как решает: fail-open или fail-closed?