Bicycle/CLAUDE.md
Egor Isaev 4e41390544 dev
2026-06-26 12:52:41 +03:00

22 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project overview

Bicycle — самописный PHP MVC micro-framework (PHP >= 8.2). Название отражает идиому «изобрести велосипед».

Commands

# Установка зависимостей
composer install

# Обновление автозагрузчика после добавления классов
composer dump-autoload

# Запуск всех тестов через Docker (контейнер уже должен быть запущен)
docker exec bicycle vendor/bin/phpunit

# Один файл / один тест
docker exec bicycle vendor/bin/phpunit tests/Unit/RouteTest.php
docker exec bicycle vendor/bin/phpunit --filter testControllerAndAction tests/Unit/RouteTest.php

Composer-скриптов нет — phpunit вызывается напрямую. Запускать тесты надо именно внутри контейнера: PHP-интерпретатор живёт в Docker, volume /home/isaevea/http/Bicycle/opt/project.

Проект запускается как веб-приложение через Apache + PHP 8.2 в контейнере bicycle.

Architecture

Directory layout

index.php                          — точка входа; константы EXT, DOCROOT, APPPATH, SYSPATH
System/Classes/                    — ядро фреймворка (namespace System\Classes\)
System/Classes/HTTP/               — HTTP-инфраструктура: интерфейсы, заголовки, исключения
System/Classes/HTTP/Client/        — исходящие HTTP-запросы (Curl, Request, Response)
System/Classes/HTTP/Exception/     — HTTPException_302/403/404
App/Controller/                    — контроллеры приложения (namespace App\Controller\)
App/config/                        — конфиги (config.php; config.local.php — в .gitignore)
App/view/                          — шаблоны приложения (.html файлы с PHP-кодом)
App/media/                         — статические ресурсы (js, css, img)
Services/DataBase/, Mail/, PDF/    — пустые каталоги-заготовки под будущие сервисы (PDO / PHPMailer / dompdf); кода пока нет
System/view/                       — системные шаблоны (ошибки 404/500/403, exception)
tools/sort_html_attrs.php          — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore)
tests/                             — PHPUnit тесты

Каталоги App/Classes/ и наполнение Services/* ещё не созданы — namespace-конвенции ниже описывают, куда класть код, когда он появится, а не существующие файлы.

Autoload (composer.json)

PSR-4 с относительными путями:

  • System\System/
  • App\App/
  • Services\Services/
  • Tests\tests/ (dev)

PhpStorm может показывать предупреждение «Namespace doesn't match PSR-4» — это ложное срабатывание. Отключается в Settings → Editor → Inspections → PHP → General.

Namespaces

  • System/Classes/*.phpnamespace System\Classes;
  • System/Classes/HTTP/*.phpnamespace System\Classes\HTTP;
  • System/Classes/HTTP/Client/*.phpnamespace System\Classes\HTTP\Client;
  • System/Classes/HTTP/Exception/*.phpnamespace System\Classes\HTTP\Exception;
  • App/Controller/*.phpnamespace App\Controller;
  • App/Classes/*.phpnamespace App\Classes;
  • Services/**/*.phpnamespace Services\...;

Core classes

Класс Роль
System\Classes\Core Bootstrap, обработка ошибок, поиск файлов (findFile), константы среды
System\Classes\BaseController Голое ядро (abstract); жизненный цикл executeAction(): before() → экшен → after()
System\Classes\Controller Веб-контроллер (extends BaseController): render() + авто-CSRF в before() + json()
System\Classes\View Рендеринг .html-шаблонов через ob_start + extract + include
System\Classes\Route Автоматический роутинг: парсит URI, ищет контроллер в App/Controller/
System\Classes\Request Входящий HTTP-запрос; реализует HTTP\Request; post(), query(), body(), isAjax()
System\Classes\Response HTTP-ответ; body() — геттер/сеттер содержимого
System\Classes\MyException Кастомное исключение; зарегистрировано через set_exception_handler
System\Classes\Cookie Статический хелпер для работы с куками: get(), set(), delete()
System\Classes\Session Синглтон сессии: instance(string $name), get/set/delete/destroy/regenerate
System\Classes\Config Статический конфиг: lazy-load App/config/config.php + merge config.local.php
System\Classes\Log Файловый логгер: уровни, аудит запросов, лог ошибок; info()/error(), маскировка
System\Classes\LogReader Интерфейс чтения логов (read(), dates()); абстракция над хранилищем
System\Classes\FileLogReader Чтение/парсинг логов из файлов; реализация LogReader (позже возможен DbLogReader)
System\Classes\Validation Валидация входных данных по декларативным правилам; factory(), rule(), check(), errors()
System\Classes\CSRF Защита форм от CSRF; токен в сессии: token(), validate(), field()
System\Classes\HTTP redirect(), requestHeaders()
System\Classes\HTTP\Header Extends ArrayObject; send(), __toString() → RFC-формат
System\Classes\HTTP\Message Интерфейс: protocol(), headers(), body(), render()
System\Classes\HTTP\Request Интерфейс входящего запроса; константы методов GET/POST/PUT/...
System\Classes\HTTP\HTTPException HTTP-исключение; factory(int $code), getResponse(): Response
System\Classes\HTTP\Exception\HTTPException_302 Редирект через HTTP::redirect()
System\Classes\HTTP\Exception\HTTPException_403 403 Forbidden
System\Classes\HTTP\Exception\HTTPException_404 404 Not Found
System\Classes\HTTP\Client\Curl Исполнитель исходящих HTTP-запросов через cURL
System\Classes\HTTP\Client\Request Билдер исходящего запроса: method(), header(), json(), timeout()
System\Classes\HTTP\Client\Response Ответ внешнего запроса: status(), body(), json(), isSuccess()

Request lifecycle

index.php
  → Core::init()
  → Request::factory()        # detectUri(), Route::resolve(), заполняет post/query/body/method
  → Request::execute()        # require_once контроллера, вызывает executeAction({action}Action)
  → Response->body()          # строка с HTML
  → echo

Internal Request (System\Classes\Request)

Входящий запрос от браузера. Реализует System\Classes\HTTP\Request.

  • factory() — создаёт экземпляр, заполняет $_GET, $_POST, метод, тело, X-Requested-With
  • $initial — первый запрос; $current — текущий
  • post($key?, $value?) — getter/setter POST-данных
  • query($key?, $value?) — getter/setter GET-параметров
  • body($content?) — getter/setter тела запроса
  • method($method?) — getter/setter HTTP-метода
  • isAjax() — проверяет X-Requested-With: XMLHttpRequest
  • detectUri() — определяет URI через PATH_INFOREQUEST_URIPHP_SELF

External HTTP Client (System\Classes\HTTP\Client*)

Исходящие запросы к внешним API.

use System\Classes\HTTP\Client\{Request as HttpRequest, Curl};

$response = (new Curl())->execute(
    HttpRequest::factory('https://api.example.com/data')
        ->method('POST')
        ->json(['key' => 'value'])
        ->timeout(10)
);

if ($response->isSuccess()) {
    $data = $response->json();
}

Session (System\Classes\Session)

Синглтон, поддерживает именованные сессии.

use System\Classes\Session;

$session = Session::instance();          // сессия 'main'
$session->set('user_id', 42);
$id = $session->get('user_id');
$session->delete('user_id');
$session->regenerate();                  // новый session_id, старые данные сохраняются
$session->destroy();                     // уничтожить сессию

Статический хелпер.

use System\Classes\Cookie;

Cookie::set('token', 'abc123', time() + 3600);
$val = Cookie::get('token');
Cookie::delete('token');

Свойства по умолчанию: $expiration=0, $path='/', $domain=null, $secure=false, $httponly=true, $samesite='Lax'.

Config (System\Classes\Config)

Lazy-load конфига из App/config/config.php. Если существует App/config/config.local.php — глубоко мержится поверх базового.

use System\Classes\Config;

$db     = Config::get('db');                          // весь раздел
$params = Config::get('session', 'cookie_params');    // вложенный ключ
Config::set('foo', ['bar' => 'baz']);                 // переопределить в runtime

config.local.php исключён из git (.gitignore) — используется для переопределений на конкретном хосте.

Validation (System\Classes\Validation)

Валидация входных данных (обычно Request::post()/query()) по декларативным правилам.

use System\Classes\Validation;

$validation = Validation::factory($request->post())
    ->label('email', 'E-mail')
    ->rule('email', 'required')
    ->rule('email', 'email')
    ->rule('password', 'min_length', [8])
    ->rule('password_confirm', 'matches', ['password']);

if ($validation->check()) {
    // данные валидны
} else {
    $errors = $validation->errors();          // [поле => сообщение]
    $one    = $validation->error('email');    // сообщение одного поля или null
}

Правила: required, email, url, numeric, digit, min_length, max_length, exact_length, matches, in, regex. Правила одного поля проверяются по порядку до первой ошибки. Пустое необязательное поле (нет required) остальные правила пропускает. Сообщения — шаблоны с плейсхолдерами :field/:param1/:param2 (подстановка через strtr, как в MyException).

CSRF (System\Classes\CSRF)

Защита форм от CSRF. Токен хранится в сессии (Session), встраивается в форму скрытым полем и проверяется при обработке POST.

Контроллеры — наследники Controller — проверяют токен автоматически в before() на методах POST/PUT/PATCH/DELETE (отключается флагом $_csrf_protection = false, например для API/вебхуков). В шаблоне достаточно вставить поле:

use System\Classes\CSRF;

<form method="post">
    <?= CSRF::field() ?>
    ...
</form>

Ручная проверка (если авто-CSRF отключён или нужен свой цикл) — через CSRF::validate():

use System\Classes\HTTP\HTTPException;

if (!CSRF::validate(Request::$current->post(CSRF::$key))) {
    throw HTTPException::factory(403);
}

Контроллер создаётся в Request::execute() как new $class() без передачи запроса — доступ к данным через статический Request::$current, не через $this->request. Рабочий пример формы (валидация поверх авто-CSRF): App/Controller/FeedbackController.php.

  • token() — токен текущей сессии (создаётся при первом обращении, random_bytes(32)).
  • validate($value) — сравнение с сессией через hash_equals() (устойчиво к timing-атакам).
  • field() — готовый <input type="hidden"> с токеном.
  • CSRF::$key — имя поля/ключа (по умолчанию csrf_token).

Log (System\Classes\Log)

Файловый логгер в стиле Config/Cookie. Пишет в два файла, именованных датой: App/logs/action-{Y-m-d}.log (действия пользователя) и App/logs/error-{Y-m-d}.log (ошибки и предупреждения). Канал выбирается по уровню.

use System\Classes\Log;

Log::info('Пользователь :id вошёл', [':id' => 42]);   // подстановка :key через strtr
Log::error('Сбой оплаты');
Log::debug(...); Log::warning(...);
  • Уровни: debug(100) < info(200) < warning(300) < error(400). Пишутся только уровни не ниже порога Log::$threshold (по умолчанию из Config::get('log','threshold')debug).
  • Канал по уровню: info/debug → файл action-…, warning/error → файл error-….
  • Каталог — Log::$directoryConfig::get('log','path')APPPATH/logs. Создаётся на лету.
  • Log::requestInfo() — строка контекста текущего запроса (METHOD /uri Controller::action params=… query=… post=…) из Request::$current; чувствительные ключи (Log::$mask_keys: password, pass, csrf_token, token) маскируются ***.

Интеграция (автоматически):

  • Request::execute() пишет INFO на каждый входящий запрос (аудит — кто что делал).
  • MyException::handler() пишет ERROR на каждое неперехваченное исключение (текст + контекст запроса), и в DEVELOPMENT, и в PRODUCTION.

Каталог логов в .gitignore (/App/logs/).

Просмотр логов: страница /admin/logs (App\Controller\Admin\LogsController) с фильтрами (дата, канал, уровень, текст). Источник — через интерфейс LogReader (реализация FileLogReader парсит файлы; позже подменяется на DbLogReader). Каталог берётся из Log::directory(). Вывод сообщений в шаблоне App/view/Admin/Logs/logs.html экранируется (htmlspecialchars) — в логах есть пользовательский ввод (XSS-вектор).

HTTPException (System\Classes\HTTP\HTTPException)

use System\Classes\HTTP\HTTPException;

throw HTTPException::factory(404, 'Страница :url не найдена', [':url' => $uri]);
// или через getResponse() без throw:
$response = HTTPException::factory(403)->getResponse();

factory(int $code) создаёт HTTPException_302/403/404 или базовый класс для неизвестных кодов.

Routing (Route::resolve)

Автоматический роутинг без конфигурации, поиск контроллера по файловой системе в App/Controller/:

URL Контроллер Метод
/ IndexController indexAction()
/users UsersController indexAction()
/users/list UsersController listAction()
/users/list/42 UsersController listAction(), param(0)=42
/admin/users/edit Admin/UsersController editAction() (subdir)

Поиск файлов и имён контроллеров — без учёта регистра. Если контроллер не найден, Route возвращает flat-роутинг (не теряет action).

View path convention

  • Шаблон контента: App/view/{Controller}/{template}.html
  • Layout: System/view/views/{layout}.html
  • Core::findFile($dir, $file, 'html') ищет в APPPATH/{dir}/{file}.html, затем в SYSPATH/{dir}/{file}.html
  • $_paths инициализируется лениво при первом вызове findFile()
  • Controller::render() автоматически определяет $dir из имени класса (App\Controller\FooControllerview/Foo)

Controller hierarchy

Расстановка имён — как в проекте eoffice_v3:

  • System\Classes\BaseController (abstract) — голое ядро: жизненный цикл executeAction() (before() → экшен → after()), хуки по умолчанию пустые. Нейтрально к вебу/API.
  • System\Classes\Controller extends BaseController — веб: render() (layout + content), авто-CSRF в before(), json($data, $status) для JSON-ответов, хук layoutData() (доп. данные в layout помимо content). Контроллеры приложения наследуют его.

API-контроллер делается не отдельным классом, а флагом: extends Controller + $_csrf_protection = false + ответы через json() (так же, как в eoffice_v3).

Админка (App/Controller/Admin/): базовый App\Controller\Admin\AdminController extends Controller задаёт layout admin (боковое меню, System/view/views/admin.html, Bootstrap из /vendor/) и наполняет layoutData() пунктами меню (menu() — пока статичный список, задел под БД). before() — заготовка под проверку прав (логина пока нет, админка открыта). Конкретные страницы наследуют AdminController.

Request::execute() вызывает executeAction({action}Action), поэтому before()/after() работают для любого контроллера прозрачно. Авторизацию добавлять в before() конкретных контроллеров (логина пока нет).

Environment

Среда задаётся через SetEnv APP_ENV в .htaccess (PRODUCTION / STAGING / TESTING / DEVELOPMENT). По умолчанию — DEVELOPMENT. Значение читается в index.php через getenv('APP_ENV').

Apache

ServerName localhost добавлен в /home/isaevea/http/docker-dev/conf/Bicycle/apache2/apache2.conf. Никогда не трогать /home/isaevea/http/docker-dev/docker/php-apache/8.2.8/Dockerfile.

Tests

PHPUnit 11 в Docker-контейнере bicycle. Bootstrap: tests/bootstrap.php. Константы определяются в bootstrap. Core::init() не вызывается — findFile() инициализирует $_paths лениво.

PHPStorm: интерпретатор PHP — Docker (bicycle), volume маппинг /home/isaevea/http/Bicycle/opt/project, путь к autoload: /opt/project/vendor/autoload.php, конфиг: /opt/project/phpunit.xml.

Файл Покрытие
tests/Unit/RouteTest.php Route::resolve(), case-insensitive, params, fallback
tests/Unit/ResponseTest.php body(), status(), fluent chain
tests/Unit/MyExceptionTest.php strtr замена, code, previous
tests/Unit/CoreTest.php findFile(), getConst(), environment
tests/Unit/RequestTest.php factory(), post(), query(), body(), isAjax(), method()
tests/Unit/ViewTest.php render(), setFilename(), data extract
tests/Unit/HttpClientRequestTest.php HTTP\Client\Request builder, json, fluent
tests/Unit/CookieTest.php get(), set(), delete(), defaults
tests/Unit/SessionTest.php singleton, get/set/delete/destroy/regenerate/bind
tests/Unit/ConfigTest.php get(), set(), merge(), lazy-load
tests/Unit/HTTPExceptionTest.php factory(), subclasses, getResponse(), codes
tests/Unit/ValidationTest.php правила, matches, пропуск пустых, first-error, label/плейсхолдеры, fluent
tests/Unit/CSRFTest.php token() стабильность/формат, validate(), field(), нет токена в сессии
tests/Unit/ControllerTest.php executeAction(), порядок before/action/after, no-op хуки
tests/Unit/LogTest.php уровни/порог, каналы (action/error), формат, append, strtr, маскировка, requestInfo()
tests/Unit/FileLogReaderTest.php парсинг, фильтры (level/q/channel), newest-first, missing file, dates()

162 теста, 270 assertion — все проходят.

Frontend dependencies (через Composer)

Bootstrap 5.3, Bootstrap Icons 1.13, jQuery 3.7.1, jQuery UI 1.12, Select2 4.1 — из vendor/.