From ab89913ebf3518469b8bad707e2e3473d592e428 Mon Sep 17 00:00:00 2001 From: pronchev Date: Mon, 6 Apr 2026 15:43:45 +0300 Subject: [PATCH] Add knowledge base --- .claude/INDEX.md | 14 +++++ .claude/kb/architecture.md | 59 ++++++++++++++++++ .claude/kb/auth.md | 48 +++++++++++++++ .claude/kb/bootstrap.md | 56 +++++++++++++++++ .claude/kb/console.md | 64 ++++++++++++++++++++ .claude/kb/http.md | 70 +++++++++++++++++++++ .claude/kb/logging.md | 53 ++++++++++++++++ .claude/kb/orm.md | 80 ++++++++++++++++++++++++ .claude/kb/worker.md | 59 ++++++++++++++++++ .gitignore | 2 - CLAUDE.md | 121 ++++--------------------------------- 11 files changed, 514 insertions(+), 112 deletions(-) create mode 100644 .claude/INDEX.md create mode 100644 .claude/kb/architecture.md create mode 100644 .claude/kb/auth.md create mode 100644 .claude/kb/bootstrap.md create mode 100644 .claude/kb/console.md create mode 100644 .claude/kb/http.md create mode 100644 .claude/kb/logging.md create mode 100644 .claude/kb/orm.md create mode 100644 .claude/kb/worker.md diff --git a/.claude/INDEX.md b/.claude/INDEX.md new file mode 100644 index 0000000..4ebbc4b --- /dev/null +++ b/.claude/INDEX.md @@ -0,0 +1,14 @@ +# Pinecore Knowledge Base + +Детальная документация по реализации. См. также [CLAUDE.md](../CLAUDE.md) для базового обзора. + +## Разделы + +- [Архитектура и жизненный цикл запроса](kb/architecture.md) +- [Ядро, Config, Environment, ContainerFactory](kb/bootstrap.md) +- [HTTP слой](kb/http.md) +- [Worker и запуск](kb/worker.md) +- [Аутентификация (JWT)](kb/auth.md) +- [Логирование](kb/logging.md) +- [ORM (MongoDB)](kb/orm.md) +- [Console](kb/console.md) diff --git a/.claude/kb/architecture.md b/.claude/kb/architecture.md new file mode 100644 index 0000000..af82c62 --- /dev/null +++ b/.claude/kb/architecture.md @@ -0,0 +1,59 @@ +# Архитектура и жизненный цикл запроса + +## Общая схема + +``` +HTTP → Caddy (:80) → /worker.php → FrankenPHP worker loop + Kernel::boot($basePath) [один раз при старте] + → Environment::detect() // читает APP_ENV, дефолт 'dev' + → Config::load($configDir) // config/*.php + config/env/{env}.php + config/env/local.php + → ContainerFactory::build() // PHP-DI, в prod компилируется в var/cache/prod/ + loop (WorkerRunner::run()): + frankenphp_handle_request(fn() => + HttpApplication::handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER) + → Request::fromGlobals() // парсит метод, путь, заголовки, JSON body + → OPTIONS? → CORS headers + 204 // preflight, без роутинга + → Router::match(method, path) // regex-компиляция {param} → именованные группы + → 404 / 405+Allow / RouteMatch + → MiddlewarePipeline::run() // каждый middleware меняет Request через withContext() + → $controller($request): Response // DI-resolved invokable + → $response->withHeaders(corsHeaders())->emit() + ) + → HttpApplication::terminate() // хук для закрытия ресурсов (сейчас пустой) + → gc_collect_cycles() + → если !$keepRunning или достигнут MAX_REQUESTS — выход из цикла +``` + +## Карта компонентов + +| Файл | Класс | Роль | +|---|---|---| +| `src/Kernel.php` | `Kernel` | Статический bootstrap, хранит контейнер | +| `src/Environment.php` | `Environment` | Читает `APP_ENV`, метод `isProd()` | +| `src/Config.php` | `Config` | Загружает `config/*.php`, deep-merge с env-оверрайдами; `get('a.b.c')` | +| `src/ContainerFactory.php` | `ContainerFactory` | Строит PHP-DI контейнер; в prod включает compilation | +| `src/Http/WorkerRunner.php` | `WorkerRunner` | Цикл FrankenPHP, MAX_REQUESTS, gc | +| `src/Http/HttpApplication.php` | `HttpApplication` | CORS, роутинг, dispatch, обработка исключений | +| `src/Http/Request.php` | `Request` | Иммутабельный; `body()`, `get(key)`, `withContext()` | +| `src/Http/Response.php` | `Response` | `json()`, `error()`, `withHeader()`, `emit()` | +| `src/Http/Router.php` | `Router` | Regex-компиляция маршрутов, возвращает `RouteMatch` | +| `src/Http/MiddlewarePipeline.php` | `MiddlewarePipeline` | Последовательно прогоняет middleware через DI | +| `src/Http/RouteDefinition.php` | `RouteDefinition` | method, path, controller, middleware[] | +| `src/Http/HttpException.php` | `HttpException` | Выбрасывается из любого места → Response::error | +| `src/Auth/AuthMiddleware.php` | `AuthMiddleware` | Bearer JWT → AuthContext в request | +| `src/Auth/JwtService.php` | `JwtService` | HS256, issue/verify, lcobucci/jwt | +| `src/Log/StdoutLogger.php` | `StdoutLogger` | JSON → stdout, PSR-3 | +| `src/Log/FileLogger.php` | `FileLogger` | JSON → файл (если LOG_FILE задан) | +| `src/Orm/AbstractMongoRepository.php` | `AbstractMongoRepository` | persist/findById/delete/findOneWhere | +| `src/Orm/MongoHydrator.php` | `MongoHydrator` | hydrate/dehydrate, поддержка Embedded/EmbeddedList | +| `src/ExceptionHandler.php` | `ExceptionHandler` | Ловит Throwable вне dispatch (critical лог) | + +## Обработка исключений в dispatch + +В `HttpApplication::dispatch()` ловятся: +- `AuthException` → 401 +- `HttpException` → код из исключения +- `\JsonException` → 400 (невалидный JSON body) +- `\Throwable` → логируется, 500 + +Исключения, вылетающие **за пределы** `handleRequest()` (т.е. до/после dispatch), ловит `ExceptionHandler` в `WorkerRunner`. diff --git a/.claude/kb/auth.md b/.claude/kb/auth.md new file mode 100644 index 0000000..e10e092 --- /dev/null +++ b/.claude/kb/auth.md @@ -0,0 +1,48 @@ +# Аутентификация (JWT) + +## Компоненты + +| Файл | Класс | Роль | +|---|---|---| +| `src/Auth/JwtService.php` | `JwtService` | Выдача и верификация JWT (HS256, lcobucci/jwt) | +| `src/Auth/AuthMiddleware.php` | `AuthMiddleware` | Bearer → JwtService → UserProvider → AuthContext | +| `src/Auth/AuthContext.php` | `AuthContext` | Обёртка над объектом пользователя | +| `src/Auth/UserProviderInterface.php` | `UserProviderInterface` | Контракт: `findById(string): ?object` | +| `src/Auth/AuthException.php` | `AuthException` | → 401 в HttpApplication::dispatch() | + +## JwtService (`src/Auth/JwtService.php`) + +Конфигурируется через Config: +- `jwt.secret` — HMAC-ключ (обязателен, иначе RuntimeException при старте) +- `jwt.access_ttl` — TTL в секундах + +```php +$jwt->issue(string $userId): string // sub = $userId, iat, exp +$jwt->verify(string $tokenString): string // возвращает sub (userId), бросает AuthException +``` + +Использует `lcobucci/jwt`, HS256, `StrictValidAt` (проверяет exp с текущим временем). + +## AuthMiddleware (`src/Auth/AuthMiddleware.php`) + +1. Читает заголовок `authorization` (lowercase в Request) +2. Ожидает формат `Bearer ` +3. `JwtService::verify()` → `userId` +4. `UserProviderInterface::findById($userId)` → user object +5. `null` → `AuthException('User not found')` +6. Возвращает `$request->withContext('auth', new AuthContext($user))` + +```php +// В контроллере: +$auth = $request->get('auth'); // AuthContext +$user = $auth->user; // объект пользователя (тип зависит от приложения) +``` + +## Подключение в приложении + +В `config/services.php` нужно забиндить `UserProviderInterface`: +```php +UserProviderInterface::class => fn($c) => $c->get(UserRepository::class), +``` + +`UserRepository` должен реализовывать `UserProviderInterface` (`findById(string $id): ?object`). diff --git a/.claude/kb/bootstrap.md b/.claude/kb/bootstrap.md new file mode 100644 index 0000000..7be57f2 --- /dev/null +++ b/.claude/kb/bootstrap.md @@ -0,0 +1,56 @@ +# Ядро, Config, Environment, ContainerFactory + +## Kernel (`src/Kernel.php`) + +Статический класс, хранит единственный инстанс контейнера. + +```php +Kernel::boot(string $basePath): Container // ENV → Config → ContainerFactory +Kernel::container(): Container // бросает RuntimeException если не вызван boot() +``` + +## Environment (`src/Environment.php`) + +```php +Environment::detect(): self // getenv('APP_ENV') ?: 'dev' +$env->name(): string +$env->is(string $name): bool +$env->isProd(): bool // name === 'prod' +``` + +## Config (`src/Config.php`) + +**Загрузка** (`Config::load($configDir, $env)`): +1. Все `$configDir/*.php` — ключ = имя файла без расширения +2. Deep-merge `$configDir/env/{envName}.php` (если есть) +3. Deep-merge `$configDir/env/local.php` (если есть, всегда поверх) + +**Доступ:** +```php +$config->get('section.key', $default) // dot-notation, deep lookup +$config->get('app.cors') // возвращает array +``` + +Merge: рекурсивный для assoc-массивов, replace для списков (array_values === array). + +## ContainerFactory (`src/ContainerFactory.php`) + +```php +ContainerFactory::build(Environment $env, Config $config, string $basePath): Container +``` + +- Autowiring включён всегда +- В prod: `$builder->enableCompilation($basePath . '/var/cache/prod/')` +- Загружает `$basePath/config/services.php` — файл должен вернуть `callable($builder, $config, $basePath)` + +**Пример `config/services.php`:** +```php +return function (ContainerBuilder $builder, Config $config, string $basePath): void { + $builder->addDefinitions([ + UserProviderInterface::class => fn($c) => $c->get(UserRepository::class), + Router::class => fn() => new Router([ + new RouteDefinition('GET', '/users/{id}', GetUserController::class, [AuthMiddleware::class]), + ]), + ]); +}; +``` diff --git a/.claude/kb/console.md b/.claude/kb/console.md new file mode 100644 index 0000000..58352d3 --- /dev/null +++ b/.claude/kb/console.md @@ -0,0 +1,64 @@ +# Console + +## Компоненты + +| Файл | Класс | Описание | +|---|---|---| +| `src/Console/ConsoleApplication.php` | `ConsoleApplication` | Точка входа: парсит argv, матчит команду, запускает handler | +| `src/Console/ConsoleRouter.php` | `ConsoleRouter` | Аналог HTTP Router для консольных команд | +| `src/Console/ConsoleDefinition.php` | `ConsoleDefinition` | signature, handler class, description, options[] | +| `src/Console/OptionDefinition.php` | `OptionDefinition` | name, description, default (false = флаг) | +| `src/Console/ConsoleInput.php` | `ConsoleInput` | Распарсенные path params + options | +| `src/Console/ConsoleOutput.php` | `ConsoleOutput` | Вывод, exit code | +| `src/Console/ConsoleMatch.php` | `ConsoleMatch` | Результат матчинга (аналог RouteMatch) | + +## ConsoleDefinition + +```php +new ConsoleDefinition( + signature: 'users:create {role}', // {param} — path param из сигнатуры + handler: CreateUserCommand::class, + description: 'Create a new user', + options: [ + new OptionDefinition('dry-run', 'Do not persist', false), // флаг (default=false) + new OptionDefinition('email', 'User email', null), // опция (default=null) + ], +) +``` + +## Handler + +Invokable-класс, резолвится через DI: +```php +final class CreateUserCommand +{ + public function __invoke(ConsoleInput $input, ConsoleOutput $output): void + { + $role = $input->pathParams['role']; + $dryRun = $input->option('dry-run'); // bool для флагов + $email = $input->option('email'); // string|null для опций + + $output->writeln('Done'); + $output->setExitCode(0); + } +} +``` + +## Запуск + +```php +// bin/console +Kernel::boot(__DIR__ . '/..'); +$app = Kernel::container()->get(ConsoleApplication::class); +exit($app->run($argv)); +``` + +Команды: `bin/console [--option=value] [--flag]` +Help: `bin/console help` или `bin/console help ` + +## Argv parsing + +`ConsoleInput::parse($commandStr, $pathParams, $args, $options)`: +- `$args` = `array_slice($argv, 2)` (после имени команды) +- `--name=value` → опция +- `--name` без `=` → флаг (true) diff --git a/.claude/kb/http.md b/.claude/kb/http.md new file mode 100644 index 0000000..49441c1 --- /dev/null +++ b/.claude/kb/http.md @@ -0,0 +1,70 @@ +# HTTP слой + +## RouteDefinition (`src/Http/RouteDefinition.php`) + +```php +new RouteDefinition( + method: 'GET', + path: '/users/{id}', + controller: GetUserController::class, + middleware: [AuthMiddleware::class], // опционально +) +``` + +## Router (`src/Http/Router.php`) + +- Компилирует `{param}` → именованные regex-группы `(?P[^/]+)` +- `match(method, path): RouteMatch` + - Сначала проверяет все маршруты на совпадение пути + - Если путь совпал, но метод нет → `RouteMatch::methodNotAllowed($allowedMethods)` + - Если ничего → `RouteMatch::notFound()` + +## MiddlewarePipeline (`src/Http/MiddlewarePipeline.php`) + +Резолвит каждый middleware через DI и вызывает `process(Request): Request` последовательно. +Middleware может изменять request через `withContext()` или бросать исключение. + +**Интерфейс middleware:** +```php +interface MiddlewareInterface +{ + public function process(Request $request): Request; +} +``` + +## Request (`src/Http/Request.php`) + +Иммутабельный объект. Конструктор принимает именованные параметры. + +```php +$request->method // 'GET', 'POST', ... +$request->path // '/users/123' +$request->query // $_GET +$request->pathParams // ['id' => '123'] — устанавливается роутером +$request->headers // lowercase-hyphen: 'content-type', 'authorization' +$request->cookies +$request->files +$request->body() // decoded JSON array (только если Content-Type: application/json) +$request->get('auth') // значение из context, установленного middleware +$request->withContext('key', $value): self // иммутабельное добавление в context +``` + +**Разбор заголовков:** `HTTP_*` из `$_SERVER` → lowercase-hyphen. `CONTENT_TYPE` тоже нормализуется. + +## Response (`src/Http/Response.php`) + +```php +Response::json($data, $status = 200): self // Content-Type: application/json +Response::error($message, $status): self // json(['error' => $message], $status) + +$response->withHeader($name, $value): self +$response->withHeaders($headers): self +$response->emit(): void // http_response_code + headers + echo body +``` + +## HttpException (`src/Http/HttpException.php`) + +```php +throw new HttpException('Forbidden', 403); +// → перехватывается в HttpApplication::dispatch() → Response::error(message, code) +``` diff --git a/.claude/kb/logging.md b/.claude/kb/logging.md new file mode 100644 index 0000000..6f89f08 --- /dev/null +++ b/.claude/kb/logging.md @@ -0,0 +1,53 @@ +# Логирование + +## Классы + +| Файл | Класс | Описание | +|---|---|---| +| `src/Log/StdoutLogger.php` | `StdoutLogger` | JSON → php://stdout | +| `src/Log/FileLogger.php` | `FileLogger` | JSON → файл | +| `src/Log/CompositeLogger.php` | `CompositeLogger` | Делегирует нескольким логгерам | +| `src/Log/NullLogger.php` | `NullLogger` | Отбрасывает все сообщения | + +Все реализуют `Psr\Log\LoggerInterface`. + +## Формат вывода (StdoutLogger) + +JSON-строка на каждое сообщение: +```json +{"ts":"2026-04-06T12:00:00.000Z","level":"error","channel":"app","message":"...","context":{...}} +``` + +- `ts` — ISO 8601 с миллисекундами +- `channel` — из `log.channel` в config (дефолт `app`) +- `context` — сериализуется; `Throwable` → `{class, message, file, line, trace}` +- PSR-3 интерполяция `{key}` из context работает для строк/scalar + +## Конфигурация + +| Env / Config key | Описание | +|---|---| +| `LOG_LEVEL` / `log.level` | Минимальный уровень (debug/info/notice/warning/error/critical/alert/emergency), дефолт `debug` | +| `LOG_FILE` / `log.file` | Путь к файлу → `FileLogger` включается автоматически | +| `log.channel` | Имя канала в выводе, дефолт `app` | + +## Добавление бэкенда + +В `config/services.php` в фабрике `LoggerInterface`: +```php +LoggerInterface::class => fn($c) => new CompositeLogger(array_filter([ + $c->get(StdoutLogger::class), + $config->get('log.file') ? $c->get(FileLogger::class) : null, + // добавить сюда +])), +``` + +## Использование + +```php +// Inject LoggerInterface через DI +$this->logger->error('Something failed', [ + 'exception' => $e, + 'path' => $request->path, +]); +``` diff --git a/.claude/kb/orm.md b/.claude/kb/orm.md new file mode 100644 index 0000000..0d559cd --- /dev/null +++ b/.claude/kb/orm.md @@ -0,0 +1,80 @@ +# ORM (MongoDB) + +## Атрибуты + +| Атрибут | Применяется к | Описание | +|---|---|---| +| `#[Collection(name: 'users')]` | класс entity | Имя коллекции MongoDB | +| `#[Id]` | свойство | Кастомное поле `id` (строка), НЕ MongoDB `_id` | +| `#[Field]` | свойство | Обычное поле; имя в MongoDB = имя свойства | +| `#[Field(name: 'x')]` | свойство | Поле с другим именем в MongoDB | +| `#[Embedded]` | свойство | Вложенный объект (не Entity, просто класс) | +| `#[EmbeddedList]` | свойство | Массив вложенных объектов | +| `#[ForEntity(Foo::class)]` | класс репозитория | Связывает репозиторий с entity | + +## Объявление Entity + +```php +#[Collection(name: 'users')] +final class User implements MongoEntity +{ + public function __construct( + #[Id] public readonly string $id, + #[Field] public readonly string $email, + #[Field] public readonly ?string $name = null, + // массив string[] — BSONArray конвертируется автоматически + #[Field] public readonly array $roles = [], + ) {} +} +``` + +- `#[Id]` хранится в документе как поле `id` (не `_id`) +- MongoDB `_id` остаётся нативным ObjectId, пинекор его игнорирует +- `array` поля (в т.ч. `string[]`) — `BSONArray` конвертируется в PHP array прозрачно +- Если `$id === ''` при `persist()`, генерируется новый ID через `IdGenerator` + +## Объявление репозитория + +```php +#[ForEntity(User::class)] +final class UserRepository extends AbstractMongoRepository +{ + // Публичный типизированный метод — не override protect persist() + public function save(User $user): User + { + return $this->persist($user); + } + + public function findByEmail(string $email): ?User + { + return $this->findOneWhere(['email' => $email]); + } +} +``` + +## AbstractMongoRepository (`src/Orm/AbstractMongoRepository.php`) + +Инжектируется через DI: `Database`, `MongoHydrator`, `IdGenerator`. + +| Метод | Описание | +|---|---| +| `persist(object $entity): object` | upsert по полю `id`; если `id === ''`, генерирует новый | +| `findById(string $id): ?object` | поиск по полю `id` | +| `findOneWhere(array $filter): ?object` | произвольный MongoDB filter | +| `delete(string $id): void` | deleteOne по полю `id` | +| `collection(string $entityClass): Collection` | MongoDB\Collection для entity | +| `entityClass(): string` | читает `#[ForEntity]`, кешируется статически | + +## MongoHydrator (`src/Orm/MongoHydrator.php`) + +- `hydrate(string $class, array|BSONDocument $doc): object` — BSONDocument/array → typed entity через named constructor args +- `dehydrate(object $entity): array` — entity → array для MongoDB +- Рекурсивно обрабатывает `#[Embedded]` и `#[EmbeddedList]` +- Embedded при dehydrate: сериализует все свойства ReflectionClass (без атрибутов) + +## EntityMap (`src/Orm/EntityMap.php`) + +Кеширует метаданные entity (reflection). `EntityMap::of(ClassName::class)` возвращает объект с: +- `collectionName` — из `#[Collection]` +- `fields` — список `FieldMetadata` (property, field name, флаги embedded) +- `idField()` — `FieldMetadata` поля с `#[Id]` diff --git a/.claude/kb/worker.md b/.claude/kb/worker.md new file mode 100644 index 0000000..f16b0ae --- /dev/null +++ b/.claude/kb/worker.md @@ -0,0 +1,59 @@ +# Worker и запуск + +## Entrypoint (`worker.php` приложения) + +FrankenPHP запускается как `frankenphp run --config Caddyfile` и сам стартует PHP-воркеры. + +```php +use Pronchev\Pinecore\Kernel; +use Pronchev\Pinecore\Http\WorkerRunner; + +require __DIR__ . '/vendor/autoload.php'; + +Kernel::boot(__DIR__); +Kernel::container()->get(WorkerRunner::class)->run(); +``` + +`WorkerRunner` резолвится через DI autowiring — конфигурировать не нужно. + +## WorkerRunner (`src/Http/WorkerRunner.php`) + +```php +final class WorkerRunner +{ + public function __construct( + private readonly HttpApplication $app, + private readonly ExceptionHandler $exceptionHandler, + ) {} + + public function run(): void + { + $maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0); // 0 = без лимита + + for ($n = 0; !$maxRequests || $n < $maxRequests; ++$n) { + $keepRunning = frankenphp_handle_request(function (): void { + try { + $this->app->handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER); + } catch (\Throwable $e) { + $this->exceptionHandler->handleException($e); // critical лог + } + }); + + $this->app->terminate(); // хук: закрытие ресурсов (сейчас пустой) + gc_collect_cycles(); + + if (!$keepRunning) break; // FrankenPHP сигнализирует об остановке + } + } +} +``` + +## Env-переменные + +| Переменная | По умолчанию | Описание | +|---|---|---| +| `APP_ENV` | `dev` | Среда исполнения | +| `MAX_REQUESTS` | `0` | Лимит запросов на воркер (0 = без лимита) | +| `LOG_LEVEL` | `debug` | Минимальный уровень логирования | +| `LOG_FILE` | — | Путь к файлу лога (активирует FileLogger) | +| `JWT_SECRET` | обязателен | HMAC-ключ для JWT | diff --git a/.gitignore b/.gitignore index 9c0c3f7..44b8b1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ .idea /vendor/ var/ -.claude/ -CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md index 65ba3bd..895a477 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code when working with this repository. ## Package @@ -10,117 +10,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co No lint or test commands configured yet. -## Architecture +## Knowledge base -**Request lifecycle:** +Detailed implementation docs: [.claude/INDEX.md](.claude/INDEX.md) -``` -HTTP → Caddy (:80) → /worker.php → FrankenPHP worker loop - Kernel::boot() [once on startup] - → Environment::detect() → Config::load() → ContainerFactory::build() - loop: - → HttpApplication::handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER) - → Request::fromGlobals() - → OPTIONS? → CORS headers + 204 (no routing) - → Router::match() → 404 / 405 / RouteMatch - → MiddlewarePipeline::run() - → Controller::__invoke(Request): Response - → Response + CORS headers → emit() - → Application::terminate() - → gc_collect_cycles() -``` - -**Key components:** - -| Path | Purpose | -|---|---| -| `src/Kernel.php` | One-time bootstrap | -| `src/Http/` | `HttpApplication`, `WorkerRunner`, `Request`, `Response`, `Router`, `RouteDefinition`, `MiddlewarePipeline`, `HttpException` | -| `src/Console/` | `ConsoleApplication`, `ConsoleRouter`, `ConsoleInput`, `ConsoleOutput`, `ConsoleDefinition` | -| `src/Auth/` | `JwtService`, `AuthMiddleware`, `AuthContext`, `AuthException`, `UserProviderInterface` | -| `src/Log/` | `StdoutLogger`, `FileLogger`, `CompositeLogger`, `NullLogger` | -| `src/Orm/` | `AbstractMongoRepository`, `MongoHydrator`, `EntityMap`, `IdGenerator`, attributes | -| `src/Model/` | Marker interfaces: `Entity`, `MongoEntity`, `SqlEntity`, `Dto` | -| `src/ExceptionHandler.php` | Catches `Throwable` escaping the worker loop | -| `src/Config.php` | Static config loader: `Config::get('section.key')` | -| `src/ContainerFactory.php` | Builds PHP-DI container from `config/services.php` | - -## Worker entrypoint - -FrankenPHP запускается бинарником (`frankenphp run --config Caddyfile`), который сам стартует PHP-воркеры. `worker.php` приложения должен: - -```php -use Pronchev\Pinecore\Kernel; -use Pronchev\Pinecore\Http\WorkerRunner; - -require __DIR__ . '/vendor/autoload.php'; - -Kernel::boot(__DIR__); -Kernel::container()->get(WorkerRunner::class)->run(); -``` - -`WorkerRunner` резолвится через DI autowiring, конфигурировать не нужно. -`MAX_REQUESTS` env var ограничивает число запросов на воркер (0 = без лимита). - -## HTTP - -```php -// RouteDefinition: method, path, controller class, middleware array -new RouteDefinition('GET', '/users/{id}', GetUserController::class, [AuthMiddleware::class]) - -// Controller — invokable, resolved via DI -final class GetUserController { - public function __invoke(Request $request): Response { - $id = $request->pathParams['id']; // path param - $user = $request->get('auth')->user; // from AuthMiddleware - return Response::json([...]); - } -} - -// Throw from anywhere — caught centrally -throw new HttpException('Forbidden', 403); -``` - -## Auth - -`AuthMiddleware` requires `UserProviderInterface` to be bound in DI: -```php -// UserProviderInterface: findById(string $id): object -// In app's config/services.php: -UserProviderInterface::class => fn($c) => $c->get(UserRepository::class), -``` - -`JwtService::issue(string $userId): string` — issues a JWT. Accepts only a string ID, no dependency on app models. - -## Logging - -```php -// Inject LoggerInterface via DI constructor -$this->logger->error('Something failed', ['exception' => $e, 'path' => $request->path]); -``` - -Output: JSON to stdout — `ts`, `level`, `channel`, `message`, `context`. -Level: `LOG_LEVEL` env var (default `debug`). -File logging: set `LOG_FILE=/path/to/file.log` — `FileLogger` activates automatically. -To add a backend: extend the `array_filter([...])` in the `LoggerInterface` factory in `config/services.php`. - -## ORM - -Entities implement `MongoEntity`, use PHP 8 attributes: -```php -#[Collection(name: 'users')] -final class User implements MongoEntity { - public function __construct( - #[Id] public readonly string $id, - #[Field] public readonly string $email, - ) {} -} -``` - -Repository extends `AbstractMongoRepository`, declare `#[ForEntity(Foo::class)]` on the class. -Implement typed `save(Foo $e): Foo { return $this->persist($e); }` — not an override of the protected `persist()`. -`#[Id]` maps to a custom field (`id`), not MongoDB's `_id` (which remains a native ObjectId). -`array` fields (e.g. `string[]`) are hydrated automatically — BSONArray is converted transparently. +- [Architecture & request lifecycle](.claude/kb/architecture.md) +- [Bootstrap: Kernel, Config, Environment, ContainerFactory](.claude/kb/bootstrap.md) +- [HTTP layer: Request, Response, Router, Middleware](.claude/kb/http.md) +- [Worker entrypoint & WorkerRunner](.claude/kb/worker.md) +- [Auth: JWT, AuthMiddleware](.claude/kb/auth.md) +- [Logging](.claude/kb/logging.md) +- [ORM: MongoDB, entities, repositories](.claude/kb/orm.md) +- [Console](.claude/kb/console.md) ## Code Style