«Рабочие места» для цифровых кочевников: реализация прагматичного API

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.
Карта Кипра с "рабочими местами" для цифровых кочевников
Карта Кипра с "рабочими местами" для цифровых кочевников

Ещё один небольшой pet-проект: про кафе и коворкинги на солнечном Кипре. "Рабочие места" для цифровых кочевников ヽ(。_°)ノ

Делюсь процессом разработки, фичами и граблями. Общий подход к разработке прагматичен и аналогичен предыдущему проекту.

Цели проекта

Кафе, кофеен, кафенио, таверн, ресторанов и баров на острове очень много, но далеко не в каждом можно спокойно поработать хотя бы пару часов. Есть, конечно, широкоизвестные Starbucks, Costa Coffee, Gloria Jeans Coffee и т.д., но ещё есть очень уютные и совершенно недооценённые локальные заведения. Поэтому было решено:

  1. Категоризовать места по актуальным для удалённой работы параметрам: кафе/коворкинг, розетки, шум, размер, занятость, вид из окна и т.д.

  2. Фильтровать места по выбранным параметрам

  3. Показать карту с подходящими местами

  4. Реализовать десктопную и мобильную версию веб-приложения

Всё удалось, код проекта открыт, велкам в пул-реквесты. Адрес сайта - в конце статьи, чтобы меньше походило на рекламу.

Для достижения целей было решено реализовать REST API микросервис на Laravel с админкой на Twill и фронтэнд веб-приложение на Vue (подробнее во второй части). Деплой, как и прежде, на Fly.io.

REST API микросервис

В качестве платформы выбран знакомый и лёгкий Laravel и PHP 8.1 с promoted- и readonly- properties и строгой типизацией.

composer.json и конфигурация проекта максимально облегчены: удалены неиспользуемые пакеты и классы, отключен platform-check, включен classmap-authoritative.

Благодаря этому количество загружаемых классов уменьшилось в 4,5 раза с 28247 до 6230 штук, каталог vendor "похудел" почти в 1,5 раза, тесты стали проходить чуть быстрее.

Архитектура

Основная модель Place - типичная Laravel-модель с прослойкой из модели Twill (A17\Twill\Models\Model).

Свойства для фильтрации - нативные PHP enum'ы с несколькими общими методами из трейта EnumValues для получения значений для админки. Кастятся в свойства модели.

Кроме того, у каждого свойства есть коэффициент и вес для расчёта рейтинга заведения. Например, наличие розеток более важно, чем вид из окна.

enum Sockets: string implements PropertyEnum
{
    use EnumValues;

    case None = 'None';
    case Few = 'Few';
    case Many = 'Many';

    public const WEIGHT = 3;


    public static function default(): self
    {
        return self::Few;
    }


    /** @inheritDoc */
    public function coefficient(): int
    {
        return match ($this) {
            self::None => 1,
            self::Few => 3,
            self::Many => 5,
        };
    }
}

Запросы к API обрабатываются single-action контроллерами, валидируются Request'ами в т.ч. по совпадению с enum'ами. Например, IndexRequest.

#[OA\Parameter(name: 'busyness', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'city', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'size', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'sockets', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'noise', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'type', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'view', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'cuisine', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'vRate', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string', format: 'float', maximum: 0, minimum: 5))]
final class IndexRequest extends FormRequest
{
    /** @return array{busyness: string, city: string, size: string, sockets: string, noise: string, type: string, view: string} */
    public function rules(): array
    {
        return [
            'busyness' => ['sometimes', 'required', new Enum(Busyness::class)],
            'city' => ['sometimes', 'required', new Enum(City::class)],
            'size' => ['sometimes', 'required', new Enum(Size::class)],
            'sockets' => ['sometimes', 'required', new Enum(Sockets::class)],
            'noise' => ['sometimes', 'required', new Enum(Noise::class)],
            'type' => ['sometimes', 'required', new Enum(Type::class)],
            'view' => ['sometimes', 'required', new Enum(View::class)],
            'cuisine' => ['sometimes', 'required', new Enum(Cuisine::class)],
            'vRate' => ['sometimes', 'required', 'float', 'numeric', 'between:0,5'],
        ];
    }
}

Нативные PHP-аттрибуты позволили разместить OpenAPI-разметку гораздо компактнее, чем в DocBlock'ах. Итоговый openapi.yaml создаётся с помощью swagger-php и используется для тестирования API.

Кроме валидаторов, запросы проходят через фильтры на основе EloquentFilter - очень выразительное решение вместо кучи if'ов и when'ов.

У некоторых заведений есть фотографии, которые прозрачно загружаются в AWS S3 из админки и обрабатываются сервисом Imgix. На стороне API нет ничего для работы с картинками.

Для получения подробных geo-данных для заведения из Google Maps используется GooglePlacesService и пакет alexpechkarev/google-maps. В API сервис все заведения добавляются только с названием, городом и свойствами для рейтинга. Остальное - координаты, идентификаторы компании, адрес и ссылка получаются в 2 шага из Google Places API.

Для расчёта рейтинга заведения используется VRateService.

Оба сервиса завёрнуты в соответствующие экшены и доступны через консольные команды и события после записи заведения.

Готовые данные оборачиваются в PlaceResource и PlaceCollection. Там же из них удаляются лишние поля. Для принудительного ответа в JSON-формате используется middleware JsonResponse.php

final class JsonResponse
{
    /** @param Closure(Request): (BaseJsonResponse) $next */
    public function handle(Request $request, Closure $next): BaseJsonResponse
    {
        $request->headers->set('Accept', 'application/json');

        return $next($request);
    }
}

Административная панель управления

Ранее я уже работал с Twill, поэтому решил использовать его для своего проекта: открытая бесплатная система с богатыми возможностями и хорошей поддержкой. Why not? :-)

Ставится через composer require area17/twill, добавляет несколько миграций и прозрачно связывается с существующими моделями. В некоторых случаях необходимо добавить к ним служебные поля типа published и дат начала/окончания аквтивности. Впрочем, в документации всё подробно описано.

Сейчас рекомендую попробовать версию 3-beta: в ней гораздо больше возможностей программного управления данными на страницах вместо отдельных виджетов в blade-шаблонах.

Пример контроллера раздела, репозитария и шаблона.

БД

Простая и быстрая SQLite ¯_(ツ)_/¯

На хостинге размещена на persistent volume. Никаких настроек не потребовалось.

Тесты

Для тестов используется Pest с поддержкой Laravel, параллельным выполнением тестов и отключенным тротлингом ($this->withoutMiddleware(ThrottleRequests::class)).

По эндпойтам проверяется адекватость ответов по dataset'ам и их соответствие с OpenAPI-спецификацией.

Для ручной проверки есть Rector с некоторыми исключениями.

Нашёлся один минус: laravel/dusk и php-webdriver/webdriver прибиты к Twill и требуют обязательной установки, хотя в моих тестах не используются :-(

Деплой

Для размещения сервера используется платформа Fly.io с управляемыми microVM Firecracker. Она никогда не спит, имеет хороший free tier и позволяет разместить как статику, так и любой сервер приложений. Кроме того, сама терминирует https-трафик, управляет сертификатами, предоставляет различные стратегии деплоя и отката изменений, health check'и и имеет широкую географию дата-центров.

Настроить среду выполнения можно автоматически командой flyctl launch из каталога приложения или написать свои конфиг и Dockerfile.

Я использовал свой Dockerfile и запуск микросервиса API самым простым способом через php artisan serve.

Раздачу статики (ассеты админки и robots.txt & Co) можно делегировать платформе Fly посредством настройки fly.toml

[[statics]]
guest_path = "/var/www/html/public/assets"
url_prefix = "/assets"

CI/CD

Всё просто: Github Action из одного workflow и тот же самый flyctl.

Мониторинг

Для отслеживания ошибок используется Sentry, а для аптайма и доступности - Honeybadger.

На этом этапе микросервис API работает, размещён в production-окружении и доступен всем пользователям. План-минимум выполнен :-)

Репозиторий API, сайт https://workplaces.cy/

Во второй части расскажу про создание фронтэнда на Vue 3 Composition API.

Источник: https://habr.com/ru/post/694142/


Интересные статьи

Интересные статьи

За последние несколько лет индустрия цифровых камер пережила настоящий бум. Благодаря конкуренции между основными игроками профессиональные средства захвата изображения стали куда доступнее. Кажется, ...
Всем привет! Не так давно на работе в рамках тестирования нового бизнес-процесса мне понадобилась возможность авторизации под разными пользователями. Переход в соответствующий р...
У нас было 2 мешка травы, 75 таблеток мескалина unix environment, docker репозиторий и задача реализовать команды docker pull и docker push без докер клиента. Читать д...
Как-то у нас исторически сложилось, что Менеджеры сидят в Битрикс КП, а Разработчики в Jira. Менеджеры привыкли ставить и решать задачи через КП, Разработчики — через Джиру.
Несмотря на то, что “в коробке” с Битриксом уже идут модули как для SOAP (модуль “Веб сервисы” в редакции “Бизнес” и старше), так и для REST (модуль “Rest API” во всех редакциях, начиная с...