327 lines
18 KiB
Markdown
327 lines
18 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
# Установка зависимостей
|
||
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/*.php` → `namespace System\Classes;`
|
||
- `System/Classes/HTTP/*.php` → `namespace System\Classes\HTTP;`
|
||
- `System/Classes/HTTP/Client/*.php` → `namespace System\Classes\HTTP\Client;`
|
||
- `System/Classes/HTTP/Exception/*.php` → `namespace System\Classes\HTTP\Exception;`
|
||
- `App/Controller/*.php` → `namespace App\Controller;`
|
||
- `App/Classes/*.php` → `namespace App\Classes;`
|
||
- `Services/**/*.php` → `namespace 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\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_INFO` → `REQUEST_URI` → `PHP_SELF`
|
||
|
||
### External HTTP Client (System\Classes\HTTP\Client\*)
|
||
|
||
Исходящие запросы к внешним API.
|
||
|
||
```php
|
||
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)
|
||
|
||
Синглтон, поддерживает именованные сессии.
|
||
|
||
```php
|
||
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(); // уничтожить сессию
|
||
```
|
||
|
||
### Cookie (System\Classes\Cookie)
|
||
|
||
Статический хелпер.
|
||
|
||
```php
|
||
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` — глубоко мержится поверх базового.
|
||
|
||
```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()`) по декларативным правилам.
|
||
|
||
```php
|
||
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/вебхуков). В шаблоне достаточно вставить поле:
|
||
|
||
```php
|
||
use System\Classes\CSRF;
|
||
|
||
<form method="post">
|
||
<?= CSRF::field() ?>
|
||
...
|
||
</form>
|
||
```
|
||
|
||
Ручная проверка (если авто-CSRF отключён или нужен свой цикл) — через `CSRF::validate()`:
|
||
|
||
```php
|
||
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`).
|
||
|
||
### HTTPException (System\Classes\HTTP\HTTPException)
|
||
|
||
```php
|
||
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\FooController` → `view/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-ответов. **Контроллеры приложения наследуют его.**
|
||
|
||
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
|
||
|
||
`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 хуки |
|
||
|
||
**145 тестов, 229 assertion — все проходят.**
|
||
|
||
### Frontend dependencies (через Composer)
|
||
|
||
Bootstrap 5.3, Bootstrap Icons 1.13, jQuery 3.7.1, jQuery UI 1.12, Select2 4.1 — из `vendor/`.
|