Compare commits
29 Commits
5e68f7dd64
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d5cf49cfd | |||
| 9470fa521b | |||
| 32d7fe4b91 | |||
| b810449536 | |||
| 3f7e80e03f | |||
| 0f68d610f5 | |||
| f8b28c6d55 | |||
| c71b61472d | |||
| c649f9b1cb | |||
| 7740fa2ecd | |||
| ecabd23142 | |||
| cfa33ad371 | |||
| 4dd374ca8d | |||
| 4eccef1783 | |||
| 88e9bcaeb1 | |||
| 5f1d8bd46e | |||
| 626a478d6c | |||
| 25a0b268ed | |||
| a4381f7f9c | |||
| b13885b002 | |||
| 6dd41c7b47 | |||
| ac5b5f9246 | |||
| 1d97114915 | |||
| ece0147b3a | |||
| d133898383 | |||
| 9f8c2b1959 | |||
| 404e2089eb | |||
| 58c9b298db | |||
| 72415949e3 |
@@ -1,62 +0,0 @@
|
|||||||
# Knowledge Base Index
|
|
||||||
|
|
||||||
Полный индекс базы знаний проекта Pinecore. Все файлы в этой директории коммитятся в репозиторий.
|
|
||||||
|
|
||||||
## Быстрая навигация
|
|
||||||
|
|
||||||
| Что нужно | Файл |
|
|
||||||
|-----------|------|
|
|
||||||
| Я только что открыл проект | [`architecture/overview.md`](architecture/overview.md) |
|
|
||||||
| Как поднять worker / запустить? | [`development/commands.md`](development/commands.md) |
|
|
||||||
| Какой паттерн использовать? | [`development/patterns.md`](development/patterns.md) |
|
|
||||||
| Commit/branch/namespace conventions | [`development/conventions.md`](development/conventions.md) |
|
|
||||||
| Как отладить локально | [`development/testing.md`](development/testing.md) |
|
|
||||||
| Ядро, Config, Environment, ContainerFactory | [`architecture/bootstrap.md`](architecture/bootstrap.md) |
|
|
||||||
| HTTP: Request, Response, Router, Middleware | [`architecture/http.md`](architecture/http.md) |
|
|
||||||
| Worker и WorkerRunner | [`architecture/worker.md`](architecture/worker.md) |
|
|
||||||
| Auth (JWT, AuthMiddleware) | [`architecture/auth.md`](architecture/auth.md) |
|
|
||||||
| Логирование | [`architecture/logging.md`](architecture/logging.md) |
|
|
||||||
| ORM (MongoDB) | [`architecture/orm.md`](architecture/orm.md) |
|
|
||||||
| Console-команды | [`architecture/console.md`](architecture/console.md) |
|
|
||||||
| Почему архитектура именно такая | [`decisions/README.md`](decisions/README.md) |
|
|
||||||
| Контекст текущей задачи | [`tasks/active/`](tasks/active/) |
|
|
||||||
|
|
||||||
## Структура директории
|
|
||||||
|
|
||||||
```
|
|
||||||
.claude/
|
|
||||||
├── README.md # Этот файл — индекс
|
|
||||||
├── architecture/ # Стабильные архитектурные docs
|
|
||||||
│ ├── overview.md # Обзор системы и жизненный цикл запроса
|
|
||||||
│ ├── bootstrap.md # Kernel, Config, Environment, ContainerFactory
|
|
||||||
│ ├── http.md # HTTP слой: Request, Response, Router, Middleware
|
|
||||||
│ ├── worker.md # Worker entrypoint и WorkerRunner
|
|
||||||
│ ├── auth.md # Auth: JWT, AuthMiddleware
|
|
||||||
│ ├── logging.md # Логирование
|
|
||||||
│ ├── orm.md # ORM: MongoDB, entities, repositories
|
|
||||||
│ └── console.md # Console-команды
|
|
||||||
├── development/ # Практика разработки
|
|
||||||
│ ├── patterns.md # Переиспользуемые code patterns
|
|
||||||
│ ├── conventions.md # Соглашения: коммиты, ветки, неймспейсы
|
|
||||||
│ ├── commands.md # Команды запуска и утилиты
|
|
||||||
│ └── testing.md # Отладка и тестирование
|
|
||||||
├── decisions/ # Architecture Decision Records (ADR)
|
|
||||||
│ ├── README.md # Индекс ADR + как писать
|
|
||||||
│ └── ADR-NNN-*.md
|
|
||||||
└── tasks/ # Контекст задач (по одному файлу на ветку)
|
|
||||||
├── _template.md # Шаблон для новой задачи
|
|
||||||
├── active/ # Активные ветки
|
|
||||||
└── completed/ # Смерженные задачи
|
|
||||||
```
|
|
||||||
|
|
||||||
## Правила обновления
|
|
||||||
|
|
||||||
**Во время разработки в feature-ветке:**
|
|
||||||
- Пиши только в `tasks/active/<branch-name>.md` (свой файл)
|
|
||||||
- Можешь добавить `decisions/ADR-NNN.md` (новый файл — нет конфликтов)
|
|
||||||
- НЕ трогай `architecture/` и `development/` — только в main
|
|
||||||
|
|
||||||
**После мержа в main (интегратор):**
|
|
||||||
- Читает секцию "Merge Notes" в task-файле
|
|
||||||
- При необходимости обновляет `architecture/` и `development/`
|
|
||||||
- Перемещает: `tasks/active/<branch>.md` → `tasks/completed/<branch>.md`
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# Ядро, 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/routes.php` (если есть) — файл возвращает `RouteDefinition[]`,
|
|
||||||
фреймворк автоматически создаёт `Router` и регистрирует его в контейнере
|
|
||||||
- Загружает `$basePath/config/services.php` (если есть) — файл возвращает
|
|
||||||
`callable($builder, $config, $basePath)`; загружается после routes.php и может переопределить
|
|
||||||
любые определения, включая `Router::class`
|
|
||||||
|
|
||||||
**`config/routes.php`** (конвенция, предпочтительный способ):
|
|
||||||
```php
|
|
||||||
use Pronchev\Pinecore\Http\RouteDefinition;
|
|
||||||
|
|
||||||
return [
|
|
||||||
new RouteDefinition('GET', '/users/{id}', GetUserController::class, [AuthMiddleware::class]),
|
|
||||||
new RouteDefinition('POST', '/users', CreateUserController::class, [AuthMiddleware::class]),
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
**`config/services.php`** (для DI-биндингов и переопределений):
|
|
||||||
```php
|
|
||||||
return function (ContainerBuilder $builder, Config $config, string $basePath): void {
|
|
||||||
$builder->addDefinitions([
|
|
||||||
UserProviderInterface::class => fn($c) => $c->get(UserRepository::class),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# 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<param>[^/]+)`
|
|
||||||
- `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)
|
|
||||||
```
|
|
||||||
|
|
||||||
## HttpApplication — конфигурационные зависимости
|
|
||||||
|
|
||||||
`HttpApplication` требует наличия ключа `cors` в `config/app.php`. Конфиг читается при каждом
|
|
||||||
запросе (в т.ч. OPTIONS-preflight). Если ключ отсутствует — бросается
|
|
||||||
`\RuntimeException('app.cors config is missing')`.
|
|
||||||
|
|
||||||
```php
|
|
||||||
// config/app.php
|
|
||||||
return [
|
|
||||||
'cors' => [
|
|
||||||
'origins' => '*',
|
|
||||||
'methods' => 'GET, POST, PUT, DELETE, OPTIONS',
|
|
||||||
'headers' => 'Content-Type, Authorization',
|
|
||||||
'max_age' => '86400',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
```
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# Логирование
|
|
||||||
|
|
||||||
## Классы
|
|
||||||
|
|
||||||
| Файл | Класс | Описание |
|
|
||||||
|---|---|---|
|
|
||||||
| `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,
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# 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]`
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Commands Reference
|
|
||||||
|
|
||||||
## Запуск worker
|
|
||||||
|
|
||||||
FrankenPHP стартует PHP-воркеры через Caddyfile:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
frankenphp run --config Caddyfile
|
|
||||||
```
|
|
||||||
|
|
||||||
Воркер-скрипт приложения подключается к фреймворку через `WorkerRunner`:
|
|
||||||
|
|
||||||
```php
|
|
||||||
// worker.php
|
|
||||||
$runner = new WorkerRunner($kernel);
|
|
||||||
$runner->run();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Console-команды
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php bin/console <command> [options]
|
|
||||||
```
|
|
||||||
|
|
||||||
Команды регистрируются в контейнере как сервисы с тегом (см. `architecture/console.md`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Composer
|
|
||||||
|
|
||||||
```bash
|
|
||||||
composer install # Установить зависимости из lock
|
|
||||||
composer update # Обновить зависимости
|
|
||||||
composer dump-autoload # Пересоздать autoloader
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker (если используется в приложении)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d # Запустить сервисы
|
|
||||||
docker compose build # Пересобрать образ
|
|
||||||
docker compose logs -f # Логи в реальном времени
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git shortcuts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Создать task-файл для новой ветки
|
|
||||||
cp .claude/tasks/_template.md .claude/tasks/active/<branch-name>.md
|
|
||||||
```
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# Conventions
|
|
||||||
|
|
||||||
## Commit Messages
|
|
||||||
|
|
||||||
```
|
|
||||||
[type] Краткое описание в imperative form
|
|
||||||
```
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
- `Add autoload for routes and services`
|
|
||||||
- `Fix missing CORS config exception`
|
|
||||||
- `Refactor WorkerRunner startup sequence`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Branch Naming
|
|
||||||
|
|
||||||
| Тип | Паттерн | Пример |
|
|
||||||
|-----|---------|--------|
|
|
||||||
| Feature | `feature/description` | `feature/jwt-refresh` |
|
|
||||||
| Fix | `fix/description` | `fix/cors-header` |
|
|
||||||
| Refactor | `refactor/description` | `refactor/kernel-bootstrap` |
|
|
||||||
| Knowledge base / tooling | `kebab-case` | `knowledge-base` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PHP Namespaces
|
|
||||||
|
|
||||||
| Компонент | Namespace |
|
|
||||||
|-----------|-----------|
|
|
||||||
| Core | `Pronchev\Pinecore\` |
|
|
||||||
| HTTP | `Pronchev\Pinecore\Http\` |
|
|
||||||
| Auth | `Pronchev\Pinecore\Auth\` |
|
|
||||||
| ORM | `Pronchev\Pinecore\Orm\` |
|
|
||||||
| Log | `Pronchev\Pinecore\Log\` |
|
|
||||||
| Console | `Pronchev\Pinecore\Console\` |
|
|
||||||
| Model | `Pronchev\Pinecore\Model\` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File & Class Naming
|
|
||||||
|
|
||||||
- PSR-4: файл = имя класса, PascalCase (`WorkerRunner.php` содержит `class WorkerRunner`)
|
|
||||||
- Атрибуты ORM: PascalCase (`#[Collection]`, `#[Field]`)
|
|
||||||
- Interfaces: суффикс `Interface` (`MiddlewareInterface`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Работа с задачами (.claude/tasks/)
|
|
||||||
|
|
||||||
При старте новой ветки:
|
|
||||||
```bash
|
|
||||||
cp .claude/tasks/_template.md .claude/tasks/active/<branch-name>.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
1. Во время разработки — редактируй только свой task-файл
|
|
||||||
2. Можно добавить `decisions/ADR-NNN.md` (новый файл — нет конфликтов)
|
|
||||||
3. Не трогай `architecture/` и `development/` — только в main
|
|
||||||
|
|
||||||
При мерже в main:
|
|
||||||
- Читай секцию "Merge Notes" в task-файле
|
|
||||||
- Обновляй стабильные docs если нужно
|
|
||||||
- Перемести: `tasks/active/<branch>.md` → `tasks/completed/<branch>.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Когда писать ADR
|
|
||||||
|
|
||||||
Создай `decisions/ADR-NNN-title.md` когда:
|
|
||||||
- Вводится новый архитектурный паттерн
|
|
||||||
- Принято нетривиальное решение (и кто-то может спросить "почему так?")
|
|
||||||
- Отвергнута очевидная альтернатива
|
|
||||||
|
|
||||||
ADR — только новые файлы, никогда не правь существующие.
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# Code Patterns
|
|
||||||
|
|
||||||
Переиспользуемые паттерны кодовой базы. Используй существующие — не изобретай новые без необходимости.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Kernel — статический контейнер
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Инициализация (один раз при старте воркера)
|
|
||||||
Kernel::init($container);
|
|
||||||
|
|
||||||
// Получение сервиса
|
|
||||||
$service = Kernel::getContainer()->get(MyService::class);
|
|
||||||
```
|
|
||||||
|
|
||||||
Kernel хранит единственный инстанс контейнера на весь процесс воркера.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. ContainerFactory — сборка контейнера
|
|
||||||
|
|
||||||
```php
|
|
||||||
$factory = new ContainerFactory($config, $environment);
|
|
||||||
$container = $factory->build();
|
|
||||||
```
|
|
||||||
|
|
||||||
Autoload: все файлы из `routes/` и `services/` подхватываются автоматически.
|
|
||||||
Не регистрируй маршруты и сервисы вручную в `index.php` — кладёт файлы в соответствующие директории.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Router и RouteDefinition
|
|
||||||
|
|
||||||
```php
|
|
||||||
// routes/api.php
|
|
||||||
return [
|
|
||||||
new RouteDefinition('GET', '/users/{id}', UserController::class, 'show'),
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
Router находит нужный маршрут по методу и пути, извлекает параметры и вызывает контроллер.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Middleware — цепочка обработки запроса
|
|
||||||
|
|
||||||
```php
|
|
||||||
class MyMiddleware implements MiddlewareInterface {
|
|
||||||
public function handle(Request $request, callable $next): Response {
|
|
||||||
// до
|
|
||||||
$response = $next($request);
|
|
||||||
// после
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Middleware регистрируются в контейнере и применяются ко всем запросам в воркере.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. ORM — атрибуты сущностей
|
|
||||||
|
|
||||||
```php
|
|
||||||
#[Collection('users')]
|
|
||||||
class UserEntity {
|
|
||||||
#[Field('_id')]
|
|
||||||
public string $id;
|
|
||||||
|
|
||||||
#[Field('name')]
|
|
||||||
public string $name;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Repository наследует базовый класс и получает методы `find`, `findOne`, `save`, `delete` из коробки.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. JWT Auth
|
|
||||||
|
|
||||||
```php
|
|
||||||
// AuthMiddleware автоматически валидирует Bearer-токен
|
|
||||||
// В контроллере — получить текущего пользователя из request
|
|
||||||
$user = $request->getAttribute('user');
|
|
||||||
```
|
|
||||||
|
|
||||||
`JwtService` отвечает за выпуск и верификацию токенов. Конфиг: секрет и TTL через `Config`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Config — конфигурация
|
|
||||||
|
|
||||||
```php
|
|
||||||
$value = Config::get('key');
|
|
||||||
$value = Config::get('section.key', $default);
|
|
||||||
```
|
|
||||||
|
|
||||||
Конфиг загружается из `environment.php` (или `.env`). Доступен глобально через статический метод.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. WorkerRunner — цикл обработки запросов
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Внутри worker.php
|
|
||||||
$runner = new WorkerRunner($kernel);
|
|
||||||
$runner->run(); // FrankenPHP loop: запрос → роутинг → middleware → контроллер → ответ
|
|
||||||
```
|
|
||||||
|
|
||||||
`WorkerRunner` перехватывает исключения и возвращает корректный HTTP-ответ даже при ошибке.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Testing & Debugging
|
|
||||||
|
|
||||||
## Запуск воркера локально
|
|
||||||
|
|
||||||
```bash
|
|
||||||
frankenphp run --config Caddyfile
|
|
||||||
```
|
|
||||||
|
|
||||||
Убедись, что переменные окружения заданы (`.env` или `environment.php`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Отладка запросов
|
|
||||||
|
|
||||||
Включи подробное логирование через конфиг:
|
|
||||||
|
|
||||||
```php
|
|
||||||
Config::get('log.level') // DEBUG для максимального вывода
|
|
||||||
```
|
|
||||||
|
|
||||||
Логи пишутся через `LoggerInterface` (Monolog). Смотри `architecture/logging.md` для деталей.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Частые проблемы
|
|
||||||
|
|
||||||
### Воркер не стартует
|
|
||||||
|
|
||||||
- Проверь синтаксис `Caddyfile` и путь до `worker.php`
|
|
||||||
- FrankenPHP требует `FRANKENPHP_CONFIG` или явного указания воркер-скрипта
|
|
||||||
- Проверь, что `Kernel::init()` вызван до первого запроса
|
|
||||||
|
|
||||||
### Маршрут не найден (404)
|
|
||||||
|
|
||||||
- Убедись, что файл с `RouteDefinition` лежит в `routes/` (autoload подхватывает автоматически)
|
|
||||||
- Проверь HTTP-метод и путь: параметры вида `{id}` чувствительны к паттерну
|
|
||||||
|
|
||||||
### JWT не валидируется
|
|
||||||
|
|
||||||
- Проверь, что `Config::get('auth.secret')` не пустой
|
|
||||||
- Убедись, что `AuthMiddleware` зарегистрирован в контейнере
|
|
||||||
- Время жизни токена — `Config::get('auth.ttl')`
|
|
||||||
|
|
||||||
### Ошибка CORS
|
|
||||||
|
|
||||||
- `CorsMiddleware` требует явного конфига (список разрешённых origins)
|
|
||||||
- Если конфиг не задан — бросается `RuntimeException` при старте
|
|
||||||
|
|
||||||
### MongoDB не подключается
|
|
||||||
|
|
||||||
- Проверь DSN в конфиге: `Config::get('mongodb.dsn')`
|
|
||||||
- Проверь, что MongoDB-сервис запущен и доступен из воркера
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Инспекция контейнера
|
|
||||||
|
|
||||||
```php
|
|
||||||
// В dev-режиме: посмотреть все зарегистрированные сервисы
|
|
||||||
$ids = Kernel::getContainer()->getServiceIds();
|
|
||||||
```
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Task: Краткое название
|
|
||||||
|
|
||||||
## Branch
|
|
||||||
`branch-name`
|
|
||||||
|
|
||||||
## Status
|
|
||||||
<!-- In Progress | Blocked | Ready for Review | Merged -->
|
|
||||||
In Progress
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
Что и зачем делается. Один абзац: проблема, решение, ожидаемый результат.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
- [ ] Компонент/файл A
|
|
||||||
- [ ] Компонент/файл B
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
Технические решения, принятые для этой задачи.
|
|
||||||
Ссылки на файлы архитектуры (например: `.claude/architecture/http.md`).
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
<!-- Список заполняй по ходу работы -->
|
|
||||||
- `src/...` — что изменено
|
|
||||||
|
|
||||||
## Patterns Used / Introduced
|
|
||||||
<!-- Какие паттерны из .claude/development/patterns.md использованы -->
|
|
||||||
<!-- Какие новые паттерны введены -->
|
|
||||||
- Использован: `WorkerRunner` (см. `.claude/development/patterns.md`)
|
|
||||||
|
|
||||||
## Decisions & Gotchas
|
|
||||||
<!-- Решения, о которых должны знать следующие разработчики -->
|
|
||||||
<!-- Если решение значимое — создай .claude/decisions/ADR-NNN-title.md -->
|
|
||||||
|
|
||||||
## Testing Done
|
|
||||||
<!-- Как проверено -->
|
|
||||||
|
|
||||||
## Merge Notes
|
|
||||||
<!-- Что интегратор должен сделать после мержа в main -->
|
|
||||||
<!-- Большинство задач: ничего не требуется — удали ненужное -->
|
|
||||||
- [ ] Обновить `.claude/architecture/...`
|
|
||||||
- [ ] Обновить `.claude/development/...`
|
|
||||||
- [ ] Добавить `decisions/ADR-NNN.md` для [решения]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# Task: привести базу знаний к структуре из другого проекта.
|
|
||||||
|
|
||||||
## Branch
|
|
||||||
`knowledge-base`
|
|
||||||
|
|
||||||
## Status
|
|
||||||
In Progress
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
В репозитории /home/pronchev/source/bitbucket.pltrm.net/trends выработана система накопления базы знаний о проекте и механизм развития проекта с параллельным поддержание базы знаний в актуальном состоянии. Нужно перенести принципы работы и структуру базы знаний в текущий проект.
|
|
||||||
11
.docker/php/Dockerfile
Normal file
11
.docker/php/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM php:8.2-cli-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS openssl-dev \
|
||||||
|
&& pecl install mongodb \
|
||||||
|
&& docker-php-ext-enable mongodb \
|
||||||
|
&& apk del .build-deps \
|
||||||
|
&& rm -rf /tmp/pear
|
||||||
|
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.git/
|
||||||
|
.idea/
|
||||||
|
vendor/
|
||||||
|
var/
|
||||||
32
CLAUDE.md
32
CLAUDE.md
@@ -4,24 +4,32 @@ Guidance for Claude Code when working with this repository.
|
|||||||
|
|
||||||
## Package
|
## Package
|
||||||
|
|
||||||
`pronchev/pinecore` — minimal PHP framework for FrankenPHP long-running workers.
|
`pinecore/pinecore` — minimal PHP framework for long-running workers.
|
||||||
|
|
||||||
**Namespace:** `Pronchev\Pinecore\` → `src/` (PSR-4)
|
**Namespace:** `Pinecore\Pinecore\` → `src/` (PSR-4)
|
||||||
|
|
||||||
No lint or test commands configured yet.
|
## Tests
|
||||||
|
|
||||||
|
PHP на хосте не установлен — всё гоняется через docker-compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm --no-deps php vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
PHPUnit 11. Конфиг — `phpunit.xml`. Тесты — `tests/`, фикстуры — `tests/Fixtures/`.
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base
|
||||||
|
|
||||||
Full index: [.claude/README.md](.claude/README.md)
|
Full index: [docs/README.md](docs/README.md)
|
||||||
|
|
||||||
- [Architecture & request lifecycle](.claude/architecture/overview.md)
|
- [Architecture & request lifecycle](docs/architecture/overview.md)
|
||||||
- [Bootstrap: Kernel, Config, Environment, ContainerFactory](.claude/architecture/bootstrap.md)
|
- [Bootstrap: Kernel, Config, Environment, ContainerFactory](docs/architecture/bootstrap.md)
|
||||||
- [HTTP layer: Request, Response, Router, Middleware](.claude/architecture/http.md)
|
- [HTTP layer: Request, Response, Router, Middleware](docs/architecture/http.md)
|
||||||
- [Worker entrypoint & WorkerRunner](.claude/architecture/worker.md)
|
- [Worker entrypoint & WorkerRunner](docs/architecture/worker.md)
|
||||||
- [Auth: JWT, AuthMiddleware](.claude/architecture/auth.md)
|
- [Auth: JWT, AuthMiddleware](docs/architecture/auth.md)
|
||||||
- [Logging](.claude/architecture/logging.md)
|
- [Logging](docs/architecture/logging.md)
|
||||||
- [ORM: MongoDB, entities, repositories](.claude/architecture/orm.md)
|
- [ORM: MongoDB, entities, repositories](docs/architecture/orm.md)
|
||||||
- [Console](.claude/architecture/console.md)
|
- [Console](docs/architecture/console.md)
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
|
|||||||
21
README.md
Normal file
21
README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 🌲 Pinecore
|
||||||
|
|
||||||
|
Минимальный PHP-фреймворк для долгоживущих воркеров.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require pinecore/pinecore
|
||||||
|
```
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
[`docs/README.md`](docs/README.md) — индекс базы знаний: архитектура, HTTP-слой, worker, ORM, auth, console.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
PHP 8.2+, расширение `mongodb`.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pronchev/pinecore",
|
"name": "pinecore/pinecore",
|
||||||
"description": "Minimal PHP framework for FrankenPHP long-running workers",
|
"description": "Minimal PHP framework for long-running workers",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
@@ -11,8 +11,14 @@
|
|||||||
"psr/log": "^3.0",
|
"psr/log": "^3.0",
|
||||||
"psr/clock": "^1.0"
|
"psr/clock": "^1.0"
|
||||||
},
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.0"
|
||||||
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {"Pronchev\\Pinecore\\": "src/"},
|
"psr-4": {"Pinecore\\Pinecore\\": "src/"},
|
||||||
"files": ["src/helpers.php"]
|
"files": ["src/helpers.php"]
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {"Tests\\Pinecore\\Pinecore\\": "tests/"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1828
composer.lock
generated
1828
composer.lock
generated
File diff suppressed because it is too large
Load Diff
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
php:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: .docker/php/Dockerfile
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- composer-cache:/root/.composer/cache
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
composer-cache:
|
||||||
27
docs/README.md
Normal file
27
docs/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Knowledge Base Index
|
||||||
|
|
||||||
|
База знаний проекта Pinecore.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
| Тема | Файл |
|
||||||
|
|------|------|
|
||||||
|
| Обзор и жизненный цикл запроса | [`architecture/overview.md`](architecture/overview.md) |
|
||||||
|
| Kernel, Config, Environment, ContainerFactory | [`architecture/bootstrap.md`](architecture/bootstrap.md) |
|
||||||
|
| HTTP: Request, Response, Router, Middleware, CORS | [`architecture/http.md`](architecture/http.md) |
|
||||||
|
| Worker entrypoint и WorkerRunner | [`architecture/worker.md`](architecture/worker.md) |
|
||||||
|
| Auth: JWT, AuthMiddleware | [`architecture/auth.md`](architecture/auth.md) |
|
||||||
|
| Логирование | [`architecture/logging.md`](architecture/logging.md) |
|
||||||
|
| ORM (MongoDB) | [`architecture/orm.md`](architecture/orm.md) |
|
||||||
|
| Console-команды | [`architecture/console.md`](architecture/console.md) |
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
| Тема | Файл |
|
||||||
|
|------|------|
|
||||||
|
| Команды запуска, composer, тесты, docker | [`development/commands.md`](development/commands.md) |
|
||||||
|
| Соглашения: namespace, code style | [`development/conventions.md`](development/conventions.md) |
|
||||||
|
|
||||||
|
## Architecture Decision Records
|
||||||
|
|
||||||
|
[`decisions/README.md`](decisions/README.md) — индекс ADR и шаблон для новых записей.
|
||||||
91
docs/architecture/bootstrap.md
Normal file
91
docs/architecture/bootstrap.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Ядро, Config, Environment, ContainerFactory
|
||||||
|
|
||||||
|
## Kernel (`src/Kernel.php`)
|
||||||
|
|
||||||
|
Тонкий хелпер оркестрации: ENV → Config → контейнер. Без статического состояния — entrypoint захватывает возвращаемый контейнер и из него резолвит первый сервис; дальше всё через DI.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Kernel::boot(string $basePath): Container // ENV → Config → ContainerFactory
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
// worker.php / bin/console
|
||||||
|
$container = Kernel::boot(__DIR__);
|
||||||
|
$runner = $container->get(WorkerRunner::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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, mixed return — escape hatch
|
||||||
|
```
|
||||||
|
|
||||||
|
Для типизированного fail-fast чтения предпочтителен новый API. Если значение есть, но тип не тот — `\RuntimeException` (никаких silent-кастов вроде `(int) '8mb' === 8`):
|
||||||
|
|
||||||
|
| Метод | Поведение |
|
||||||
|
|---|---|
|
||||||
|
| `requireString($key)` / `requireInt` / `requireBool` / `requireArray` | Обязательное значение нужного типа. Отсутствует → `RuntimeException("missing required key …")`. Не того типа → `RuntimeException("key … must be X, got Y")` |
|
||||||
|
| `requireStringList($key)` | `list<string>`. Отвергает ассоциативный массив и нестроковые элементы (с указанием индекса) |
|
||||||
|
| `getString($key, $default)` / `getInt` / `getBool` / `getArray` | Если ключа нет — дефолт. Если есть, но тип не тот — `RuntimeException` |
|
||||||
|
|
||||||
|
Используется во фреймворке: `JwtService` (`requireString`/`requireInt`), `CorsPolicy` (`requireStringList`), `HttpApplication` (`getInt`), `ContainerFactory` (`getString`/`getInt`), `EnsureIndexesCommand` (`getArray`).
|
||||||
|
|
||||||
|
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/')`
|
||||||
|
- Автоматически регистрирует `Config::class`, `Environment::class` и `LoggerInterface::class`
|
||||||
|
в контейнере — любой класс может получить их через DI без ручного биндинга в `services.php`
|
||||||
|
- `LoggerInterface` по умолчанию резолвится в `JsonStreamLogger`, пишущий в `php://stdout`;
|
||||||
|
если задан `log.file` — добавляется второй `JsonStreamLogger` для файла, оба
|
||||||
|
заворачиваются в `CompositeLogger`. Файл открывается на старте — недоступный путь
|
||||||
|
валит boot.
|
||||||
|
- Загружает `$basePath/config/routes.php` (если есть) — файл возвращает `RouteDefinition[]`,
|
||||||
|
фреймворк автоматически создаёт `Router` и регистрирует его в контейнере
|
||||||
|
- Загружает `$basePath/config/commands.php` (если есть) — файл возвращает `ConsoleDefinition[]`,
|
||||||
|
фреймворк автоматически создаёт `ConsoleRouter` и регистрирует его в контейнере. Если файла
|
||||||
|
нет — `ConsoleRouter` всё равно резолвится с пустым списком, чтобы `bin/console help` работал
|
||||||
|
на голом скелетоне
|
||||||
|
- Загружает `$basePath/config/services.php` (если есть) — файл возвращает
|
||||||
|
`callable($builder, $config, $basePath)`; загружается после routes.php/commands.php и может
|
||||||
|
переопределить любые определения, включая `Router::class` и `ConsoleRouter::class`
|
||||||
|
|
||||||
|
**`config/routes.php`** (конвенция, предпочтительный способ):
|
||||||
|
```php
|
||||||
|
use Pinecore\Pinecore\Http\RouteDefinition;
|
||||||
|
|
||||||
|
return [
|
||||||
|
new RouteDefinition('GET', '/users/{id}', GetUserController::class, [AuthMiddleware::class]),
|
||||||
|
new RouteDefinition('POST', '/users', CreateUserController::class, [AuthMiddleware::class]),
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**`config/services.php`** (для DI-биндингов и переопределений):
|
||||||
|
```php
|
||||||
|
return function (ContainerBuilder $builder, Config $config, string $basePath): void {
|
||||||
|
$builder->addDefinitions([
|
||||||
|
UserProviderInterface::class => fn($c) => $c->get(UserRepository::class),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -48,14 +48,43 @@ final class CreateUserCommand
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
// bin/console
|
// bin/console
|
||||||
Kernel::boot(__DIR__ . '/..');
|
$container = Kernel::boot(__DIR__ . '/..');
|
||||||
$app = Kernel::container()->get(ConsoleApplication::class);
|
$app = $container->get(ConsoleApplication::class);
|
||||||
exit($app->run($argv));
|
exit($app->run($argv));
|
||||||
```
|
```
|
||||||
|
|
||||||
Команды: `bin/console <signature> [--option=value] [--flag]`
|
Команды: `bin/console <signature> [--option=value] [--flag]`
|
||||||
Help: `bin/console help` или `bin/console help <signature>`
|
Help: `bin/console help` или `bin/console help <signature>`
|
||||||
|
|
||||||
|
## Регистрация команд
|
||||||
|
|
||||||
|
Конвенциональный способ — `config/commands.php` (по аналогии с `config/routes.php`):
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Pinecore\Pinecore\Console\ConsoleDefinition;
|
||||||
|
use Pinecore\Pinecore\Console\OptionDefinition;
|
||||||
|
|
||||||
|
return [
|
||||||
|
new ConsoleDefinition('mongo:ensure-indexes', EnsureIndexesCommand::class, 'Ensure MongoDB indexes'),
|
||||||
|
new ConsoleDefinition('users:create {role}', CreateUserCommand::class, 'Create a new user', [
|
||||||
|
new OptionDefinition('email', 'User email', null),
|
||||||
|
new OptionDefinition('dry-run', 'Do not persist', false),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
`ContainerFactory` сам соберёт `ConsoleRouter` из этого списка. Если файла нет —
|
||||||
|
`ConsoleRouter` резолвится пустым, и `bin/console help` показывает «No commands registered»
|
||||||
|
(удобно для скелетона). Переопределить биндинг полностью можно в `config/services.php`.
|
||||||
|
|
||||||
|
## Команды, поставляемые фреймворком
|
||||||
|
|
||||||
|
| Сигнатура | Хендлер | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `mongo:ensure-indexes` | `Pinecore\Pinecore\Orm\Console\EnsureIndexesCommand` | Раскатывает `#[Index]` атрибуты в MongoDB. См. `architecture/orm.md`. |
|
||||||
|
|
||||||
|
Регистрируются приложением в его console-конфиге наравне со своими командами — фреймворк сам ничего не регистрирует.
|
||||||
|
|
||||||
## Argv parsing
|
## Argv parsing
|
||||||
|
|
||||||
`ConsoleInput::parse($commandStr, $pathParams, $args, $options)`:
|
`ConsoleInput::parse($commandStr, $pathParams, $args, $options)`:
|
||||||
164
docs/architecture/http.md
Normal file
164
docs/architecture/http.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# 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<param>[^/]+)`
|
||||||
|
- `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() // распарсенное тело (см. Content-Type ниже)
|
||||||
|
$request->input('email') // значение поля из body или null
|
||||||
|
$request->input('email', 'def') // с дефолтом
|
||||||
|
$request->get('auth') // значение из context, установленного middleware
|
||||||
|
$request->withContext('key', $value): self // иммутабельное добавление в context
|
||||||
|
```
|
||||||
|
|
||||||
|
**Разбор заголовков:** `HTTP_*` из `$_SERVER` → lowercase-hyphen. `CONTENT_TYPE` и `CONTENT_LENGTH` нормализуются дополнительно — у них в `$_SERVER` нет префикса `HTTP_` (CGI, RFC 3875).
|
||||||
|
|
||||||
|
### Парсинг тела по `Content-Type`
|
||||||
|
|
||||||
|
`Request::fromGlobals` решает, как наполнить `body` исходя из заголовка:
|
||||||
|
|
||||||
|
| Content-Type | Источник `body()` |
|
||||||
|
|---|---|
|
||||||
|
| `application/json` (с любым `; charset=...`) | `json_decode($rawBody, true)` |
|
||||||
|
| `application/x-www-form-urlencoded`, метод `POST` | `$_POST` |
|
||||||
|
| `application/x-www-form-urlencoded`, прочие методы (`PUT`/`PATCH`/...) | `parse_str($rawBody, ...)` |
|
||||||
|
| `multipart/form-data` (boundary) | `$_POST` |
|
||||||
|
| Пусто или другое | `[]` |
|
||||||
|
|
||||||
|
**Ошибки парсинга** бросают `HttpException(400)` прямо из `fromGlobals`:
|
||||||
|
- невалидный JSON → `'Invalid JSON body'`
|
||||||
|
- JSON-скаляр или `null` на корне → `'JSON body must be an object or array'` (top-level `[...]` валиден)
|
||||||
|
|
||||||
|
`HttpApplication::dispatch` ловит `HttpException` и формирует `Response::error(message, 400)`. Контроллер до невалидного тела не доходит.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
## HttpApplication::handleRequest — источники данных
|
||||||
|
|
||||||
|
```php
|
||||||
|
$app->handleRequest(
|
||||||
|
array $get, array $post, array $cookie, array $files, array $server,
|
||||||
|
?string $rawBody = null,
|
||||||
|
): void
|
||||||
|
```
|
||||||
|
|
||||||
|
`$get…$server` всегда передаются вызывающим (адаптером воркер-рантайма или `WorkerRunner` в SAPI-режиме). Тело — два режима:
|
||||||
|
|
||||||
|
- `$rawBody = null` (по умолчанию) — `HttpApplication::readRawBody()` читает `php://input`. Это путь для FPM/CGI/Apache mod_php, где PHP runtime сам перенаполняет stream под запрос.
|
||||||
|
- `$rawBody = '<строка>'` — используется как есть, `php://input` не трогается. Это путь для воркер-рантаймов (RoadRunner, FrankenPHP в worker-mode и т.п.), где `php://input` не пересоздаётся между итерациями.
|
||||||
|
|
||||||
|
`readRawBody()` оставлен `protected` как тестовый хук для подмены SAPI-чтения в анонимных сабклассах.
|
||||||
|
|
||||||
|
## CORS
|
||||||
|
|
||||||
|
CORS-политика инкапсулирована в `src/Http/CorsPolicy.php` (DI-резолвится автовайрингом). Ключ `app.cors` в конфиге обязателен — `CorsPolicy` бросает в конструкторе, если секции нет.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/app.php
|
||||||
|
return [
|
||||||
|
'cors' => [
|
||||||
|
// Точный allowlist. Допустимо ['*'] — но только если allow_credentials=false.
|
||||||
|
'allowed_origins' => ['https://app.example.com', 'https://staging.example.com'],
|
||||||
|
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
'allowed_headers' => ['Content-Type', 'Authorization'],
|
||||||
|
'allow_credentials' => false,
|
||||||
|
'max_age' => 600,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Резолв `Access-Control-Allow-Origin`:**
|
||||||
|
|
||||||
|
| Запрос | Конфиг | Что эмитится |
|
||||||
|
|---|---|---|
|
||||||
|
| Без `Origin` | любой | ничего — same-origin / curl, CORS-заголовки не нужны |
|
||||||
|
| `Origin` есть, allowlist `['*']`, credentials=false | wildcard | `Allow-Origin: *`, без `Vary` |
|
||||||
|
| `Origin` есть, allowlist `['*']`, credentials=true | wildcard + creds | **ошибка конфига** — `\RuntimeException` в конструкторе. По спеке `*` несовместим с credentials |
|
||||||
|
| `Origin` есть, в allowlist | exact match | `Allow-Origin: <тот origin>` + `Vary: Origin` |
|
||||||
|
| `Origin` есть, не в allowlist | miss | заголовок не эмитится — браузер интерпретирует как fail |
|
||||||
|
|
||||||
|
**`Vary: Origin`** добавляется ко всем ответам кроме чистого `['*']` без credentials — защита от cache-poisoning, когда CDN/прокси может отдать кэшированный ответ другого origin'а.
|
||||||
|
|
||||||
|
**`Access-Control-Allow-Credentials: true`** эмитится только если включено в конфиге **и** мы выдали `Allow-Origin`.
|
||||||
|
|
||||||
|
**Preflight (OPTIONS)** обрабатывается до роутинга и middleware. Для разрешённого origin'а добавляются `Allow-Methods`, `Allow-Headers`, `Max-Age`. Для запрещённого — только `Vary: Origin` (без CORS-заголовков), 204.
|
||||||
|
|
||||||
|
**Simple-ответы** (non-OPTIONS) не содержат `Allow-Methods` / `Allow-Headers` — по спеке они preflight-only.
|
||||||
|
|
||||||
|
## HttpApplication — конфигурационные зависимости
|
||||||
|
|
||||||
|
`HttpApplication` зависит от `CorsPolicy`, `Router`, `MiddlewarePipeline`, `Config`, `LoggerInterface` — всё через DI-конструктор.
|
||||||
|
|
||||||
|
## Лимит размера тела запроса
|
||||||
|
|
||||||
|
`HttpApplication` ограничивает размер тела через ключ `http.max_body_bytes`
|
||||||
|
(дефолт — 8 MiB; `0` отключает лимит). Проверка двойная:
|
||||||
|
|
||||||
|
1. **До чтения** — если `Content-Length` превышает лимит, тело даже не читается, отдаётся `413 Payload Too Large`.
|
||||||
|
2. **После чтения** — если клиент соврал в `Content-Length` или не прислал его, фактический размер всё равно валидируется.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/app.php
|
||||||
|
return [
|
||||||
|
'http' => [
|
||||||
|
'max_body_bytes' => 8 * 1024 * 1024,
|
||||||
|
],
|
||||||
|
// ...
|
||||||
|
];
|
||||||
|
```
|
||||||
62
docs/architecture/logging.md
Normal file
62
docs/architecture/logging.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Логирование
|
||||||
|
|
||||||
|
## Классы
|
||||||
|
|
||||||
|
| Файл | Класс | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/Log/JsonStreamLogger.php` | `JsonStreamLogger` | JSON в любой `resource` (stdout, файл, memory) |
|
||||||
|
| `src/Log/CompositeLogger.php` | `CompositeLogger` | Делегирует нескольким логгерам |
|
||||||
|
| `src/Log/NullLogger.php` | `NullLogger` | Отбрасывает все сообщения |
|
||||||
|
|
||||||
|
Все реализуют `Psr\Log\LoggerInterface`. Куда писать (stdout, файл, что-то ещё) определяется на уровне DI-фабрики в `ContainerFactory` — `JsonStreamLogger` принимает уже открытый стрим и не знает ничего об источнике.
|
||||||
|
|
||||||
|
## Формат вывода
|
||||||
|
|
||||||
|
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
|
||||||
|
- Битый UTF-8 в строках заменяется на U+FFFD (`JSON_INVALID_UTF8_SUBSTITUTE`).
|
||||||
|
Если контекст всё равно не кодируется (resource, циклическая ссылка, `NAN`/`INF`), запись пишется
|
||||||
|
без `context`, а в payload добавляется `_logger_error: "context not encodable: <причина>"`.
|
||||||
|
Логгер не выбрасывает исключений наружу — приложение продолжит работу.
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
| Config key | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `log.level` | Минимальный уровень (debug/info/notice/warning/error/critical/alert/emergency), дефолт `debug` |
|
||||||
|
| `log.file` | Путь к файлу → дополнительный `JsonStreamLogger` пишет в файл (открывается на старте, fail-fast при недоступности) |
|
||||||
|
| `log.channel` | Имя канала в выводе, дефолт `app` |
|
||||||
|
|
||||||
|
`stdout` пишется всегда. Если задан `log.file`, оба логгера заворачиваются в `CompositeLogger`.
|
||||||
|
|
||||||
|
## Добавление бэкенда
|
||||||
|
|
||||||
|
Перекройте биндинг `LoggerInterface` в `config/services.php` своим — `JsonStreamLogger` принимает любой `resource`:
|
||||||
|
```php
|
||||||
|
LoggerInterface::class => function () use ($config) {
|
||||||
|
$level = JsonStreamLogger::levelValue((string) $config->get('log.level', 'debug'));
|
||||||
|
$channel = (string) $config->get('log.channel', 'app');
|
||||||
|
|
||||||
|
return new CompositeLogger([
|
||||||
|
new JsonStreamLogger(fopen('php://stdout', 'w'), $level, $channel),
|
||||||
|
new MyCustomLogger(...), // syslog, network — что угодно
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Inject LoggerInterface через DI
|
||||||
|
$this->logger->error('Something failed', [
|
||||||
|
'exception' => $e,
|
||||||
|
'path' => $request->path,
|
||||||
|
]);
|
||||||
|
```
|
||||||
171
docs/architecture/orm.md
Normal file
171
docs/architecture/orm.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# ORM (MongoDB)
|
||||||
|
|
||||||
|
## Атрибуты
|
||||||
|
|
||||||
|
| Атрибут | Применяется к | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `#[Collection(name: 'users')]` | класс entity | Имя коллекции MongoDB |
|
||||||
|
| `#[Id]` | свойство | Кастомное поле `id` (строка), НЕ MongoDB `_id` |
|
||||||
|
| `#[Field]` | свойство | Обычное поле; имя в MongoDB = имя свойства |
|
||||||
|
| `#[Field(name: 'x')]` | свойство | Поле с другим именем в MongoDB |
|
||||||
|
| `#[Embedded]` | свойство | Вложенный объект (не Entity, просто класс) |
|
||||||
|
| `#[EmbeddedList]` | свойство | Массив вложенных объектов |
|
||||||
|
| `#[Index]` | свойство (repeatable) | Объявить индекс на поле; раскатывается командой `mongo:ensure-indexes` |
|
||||||
|
| `#[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-объектах
|
||||||
|
- `#[Embedded]` и `#[EmbeddedList]` обрабатываются симметрично через `EntityMap` — при сериализации и десериализации используется одна и та же таблица полей, поэтому `#[Field(name: 'x')]` работает в embedded так же, как в top-level entity.
|
||||||
|
|
||||||
|
### Поведение `hydrate` при отсутствии поля в документе
|
||||||
|
|
||||||
|
1. Если у конструктора есть default — он применяется (поле не передаётся в named-args).
|
||||||
|
2. Иначе если свойство nullable — передаётся `null`.
|
||||||
|
3. Иначе бросается `\LogicException` с именем поля и класса.
|
||||||
|
|
||||||
|
Это позволяет безопасно эволюционировать схему (добавлять поля с дефолтами без миграции БД) и даёт понятную ошибку для обязательных пропавших полей.
|
||||||
|
|
||||||
|
## EntityMap (`src/Orm/EntityMap.php`)
|
||||||
|
|
||||||
|
Кеширует метаданные entity (reflection). `EntityMap::of(ClassName::class)` возвращает объект с:
|
||||||
|
- `collectionName` — `string` из `#[Collection]` для классов, реализующих `MongoEntity`; `null` для embedded-классов (без `#[Collection]`)
|
||||||
|
- `fields` — список `FieldMetadata` (property, field name, флаги embedded, `isNullable`, `hasConstructorDefault`)
|
||||||
|
- `idField()` — `FieldMetadata` поля с `#[Id]` (бросает `LogicException`, если нет)
|
||||||
|
|
||||||
|
`#[Collection]` обязателен только для классов, реализующих `MongoEntity`. Embedded value-objects используют те же `#[Field]`/`#[Embedded]`/`#[EmbeddedList]` атрибуты, но сами `MongoEntity` не реализуют и `#[Collection]` им не нужен.
|
||||||
|
|
||||||
|
### Валидация атрибутов
|
||||||
|
|
||||||
|
`EntityMap::build` валидирует атрибуты при первом обращении к классу (результат кешируется). `\LogicException` бросается **до** хидрации первого документа, с указанием `Класс::$свойство` для быстрой локализации.
|
||||||
|
|
||||||
|
**`#[Embedded]`** требует:
|
||||||
|
- свойство типизировано одним именованным классом (опц. nullable: `?Foo`);
|
||||||
|
- `mixed`/нетипизированное/примитив (`string`, `int`, ...)/`array`/union/intersection — отвергается;
|
||||||
|
- класс из типа существует (`class_exists`).
|
||||||
|
|
||||||
|
**`#[EmbeddedList(class: Foo::class)]`** требует:
|
||||||
|
- `Foo` существует;
|
||||||
|
- свойство типизировано как `array` (опц. `?array`).
|
||||||
|
|
||||||
|
**`#[Index]`** требует на том же свойстве `#[Field]` или `#[Id]` — иначе `\LogicException`.
|
||||||
|
|
||||||
|
## Индексы
|
||||||
|
|
||||||
|
### Атрибут `#[Index]`
|
||||||
|
|
||||||
|
Объявляется на свойстве, помеченном `#[Field]` или `#[Id]`. Repeatable — на одном поле допускается несколько индексов с разными опциями.
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[Field] #[Index(unique: true, name: 'uniq_email')]
|
||||||
|
public readonly string $email;
|
||||||
|
|
||||||
|
#[Field(name: 'created_at')] #[Index(direction: -1, expireAfterSeconds: 3600)]
|
||||||
|
public readonly int $createdAt;
|
||||||
|
|
||||||
|
#[Field] #[Index(sparse: true)]
|
||||||
|
public readonly ?string $referralCode;
|
||||||
|
```
|
||||||
|
|
||||||
|
Параметры `Index`:
|
||||||
|
|
||||||
|
| Параметр | По умолчанию | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `direction` | `1` | `1` asc, `-1` desc |
|
||||||
|
| `unique` | `false` | Уникальный индекс |
|
||||||
|
| `sparse` | `false` | Sparse-индекс (исключает документы без поля) |
|
||||||
|
| `name` | `null` | Кастомное имя; `null` — MongoDB сгенерит |
|
||||||
|
| `expireAfterSeconds` | `null` | TTL: автоматическое удаление документов |
|
||||||
|
|
||||||
|
`#[Index]` на свойстве без `#[Field]`/`#[Id]` (или на `#[Embedded]`/`#[EmbeddedList]`) → `LogicException` при построении метаданных. Имя поля в индексе берётся с учётом `#[Field(name: ...)]` — т.е. в Mongo индекс создаётся на BSON-поле, а не на имени PHP-свойства.
|
||||||
|
|
||||||
|
Метаданные доступны как `EntityMap::of(Foo::class)->indexes` — `list<IndexMetadata>` с теми же полями, что у `Index`, плюс `field` (BSON-имя).
|
||||||
|
|
||||||
|
### Команда `mongo:ensure-indexes`
|
||||||
|
|
||||||
|
Хендлер: `Pinecore\Pinecore\Orm\Console\EnsureIndexesCommand`. Читает список entity из `config/orm.php` (`orm.entities`), для каждого зовёт `Database::selectCollection(...)->createIndex(keys, options)`. `createIndex` идемпотентна — повторный запуск с тем же spec ничего не меняет.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/orm.php в приложении
|
||||||
|
return [
|
||||||
|
'entities' => [
|
||||||
|
App\Entity\User::class,
|
||||||
|
App\Entity\Order::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Регистрация команды — в console-конфиге приложения:
|
||||||
|
|
||||||
|
```php
|
||||||
|
new ConsoleDefinition(
|
||||||
|
signature: 'mongo:ensure-indexes',
|
||||||
|
handler: EnsureIndexesCommand::class,
|
||||||
|
description: 'Create/update MongoDB indexes declared via #[Index]',
|
||||||
|
options: [
|
||||||
|
new OptionDefinition('dry-run', 'Print plan without changing DB', false),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск: `php bin/console mongo:ensure-indexes [--dry-run]`. Пустой `orm.entities` → exit code 1 с сообщением в stderr.
|
||||||
|
|
||||||
|
Embedded-классы (без `#[Collection]`) пропускаются автоматически. Удаление "лишних" индексов (которые есть в БД, но не в коде) команда **не делает** — только additive. Чистка — отдельная задача (опасно для прода).
|
||||||
|
|
||||||
|
## EntityMetadata::isNewId
|
||||||
|
|
||||||
|
`AbstractMongoRepository::persist` определяет "новый ли entity" через `EntityMetadata::isNewId($idValue)` — true для `null` и `''`. Это значит, что `#[Id] public readonly ?string $id = null` и `#[Id] public readonly string $id = ''` оба корректно триггерят генерацию нового id через `IdGenerator`.
|
||||||
@@ -3,16 +3,18 @@
|
|||||||
## Общая схема
|
## Общая схема
|
||||||
|
|
||||||
```
|
```
|
||||||
HTTP → Caddy (:80) → /worker.php → FrankenPHP worker loop
|
HTTP → /worker.php → worker loop
|
||||||
Kernel::boot($basePath) [один раз при старте]
|
Kernel::boot($basePath) [один раз при старте]
|
||||||
→ Environment::detect() // читает APP_ENV, дефолт 'dev'
|
→ Environment::detect() // читает APP_ENV, дефолт 'dev'
|
||||||
→ Config::load($configDir) // config/*.php + config/env/{env}.php + config/env/local.php
|
→ Config::load($configDir) // config/*.php + config/env/{env}.php + config/env/local.php
|
||||||
→ ContainerFactory::build() // PHP-DI, в prod компилируется в var/cache/prod/
|
→ ContainerFactory::build() // PHP-DI, в prod компилируется в var/cache/prod/
|
||||||
loop (WorkerRunner::run()):
|
loop (WorkerRunner::run($loop)):
|
||||||
frankenphp_handle_request(fn() =>
|
$loop($handler) → keepRunning // адаптер ходит в свой рантайм за запросом
|
||||||
HttpApplication::handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER)
|
$handler($get, $post, $cookie, $files, $server, $rawBody) →
|
||||||
|
HttpApplication::handleRequest($get, $post, $cookie, $files, $server, $rawBody)
|
||||||
→ Request::fromGlobals() // парсит метод, путь, заголовки, JSON body
|
→ Request::fromGlobals() // парсит метод, путь, заголовки, JSON body
|
||||||
→ OPTIONS? → CORS headers + 204 // preflight, без роутинга
|
→ OPTIONS? → CorsPolicy::preflightHeaders($origin) + 204
|
||||||
|
// origin валидируется по allowlist; default-deny при miss
|
||||||
→ Router::match(method, path) // regex-компиляция {param} → именованные группы
|
→ Router::match(method, path) // regex-компиляция {param} → именованные группы
|
||||||
→ 404 / 405+Allow / RouteMatch
|
→ 404 / 405+Allow / RouteMatch
|
||||||
→ MiddlewarePipeline::run() // каждый middleware меняет Request через withContext()
|
→ MiddlewarePipeline::run() // каждый middleware меняет Request через withContext()
|
||||||
@@ -21,7 +23,7 @@ HTTP → Caddy (:80) → /worker.php → FrankenPHP worker loop
|
|||||||
)
|
)
|
||||||
→ HttpApplication::terminate() // хук для закрытия ресурсов (сейчас пустой)
|
→ HttpApplication::terminate() // хук для закрытия ресурсов (сейчас пустой)
|
||||||
→ gc_collect_cycles()
|
→ gc_collect_cycles()
|
||||||
→ если !$keepRunning или достигнут MAX_REQUESTS — выход из цикла
|
→ если !$keepRunning или достигнут worker.max_requests — выход из цикла
|
||||||
```
|
```
|
||||||
|
|
||||||
## Карта компонентов
|
## Карта компонентов
|
||||||
@@ -32,18 +34,18 @@ HTTP → Caddy (:80) → /worker.php → FrankenPHP worker loop
|
|||||||
| `src/Environment.php` | `Environment` | Читает `APP_ENV`, метод `isProd()` |
|
| `src/Environment.php` | `Environment` | Читает `APP_ENV`, метод `isProd()` |
|
||||||
| `src/Config.php` | `Config` | Загружает `config/*.php`, deep-merge с env-оверрайдами; `get('a.b.c')` |
|
| `src/Config.php` | `Config` | Загружает `config/*.php`, deep-merge с env-оверрайдами; `get('a.b.c')` |
|
||||||
| `src/ContainerFactory.php` | `ContainerFactory` | Строит PHP-DI контейнер; в prod включает compilation |
|
| `src/ContainerFactory.php` | `ContainerFactory` | Строит PHP-DI контейнер; в prod включает compilation |
|
||||||
| `src/Http/WorkerRunner.php` | `WorkerRunner` | Цикл FrankenPHP, MAX_REQUESTS, gc |
|
| `src/Http/WorkerRunner.php` | `WorkerRunner` | Worker-цикл, MAX_REQUESTS, gc |
|
||||||
| `src/Http/HttpApplication.php` | `HttpApplication` | CORS, роутинг, dispatch, обработка исключений |
|
| `src/Http/HttpApplication.php` | `HttpApplication` | CORS, роутинг, dispatch, обработка исключений |
|
||||||
| `src/Http/Request.php` | `Request` | Иммутабельный; `body()`, `get(key)`, `withContext()` |
|
| `src/Http/Request.php` | `Request` | Иммутабельный; `body()`, `get(key)`, `withContext()` |
|
||||||
| `src/Http/Response.php` | `Response` | `json()`, `error()`, `withHeader()`, `emit()` |
|
| `src/Http/Response.php` | `Response` | `json()`, `error()`, `withHeader()`, `emit()` |
|
||||||
| `src/Http/Router.php` | `Router` | Regex-компиляция маршрутов, возвращает `RouteMatch` |
|
| `src/Http/Router.php` | `Router` | Regex-компиляция маршрутов, возвращает `RouteMatch` |
|
||||||
|
| `src/Http/CorsPolicy.php` | `CorsPolicy` | Allowlist по Origin, preflight/simple-заголовки, Vary |
|
||||||
| `src/Http/MiddlewarePipeline.php` | `MiddlewarePipeline` | Последовательно прогоняет middleware через DI |
|
| `src/Http/MiddlewarePipeline.php` | `MiddlewarePipeline` | Последовательно прогоняет middleware через DI |
|
||||||
| `src/Http/RouteDefinition.php` | `RouteDefinition` | method, path, controller, middleware[] |
|
| `src/Http/RouteDefinition.php` | `RouteDefinition` | method, path, controller, middleware[] |
|
||||||
| `src/Http/HttpException.php` | `HttpException` | Выбрасывается из любого места → Response::error |
|
| `src/Http/HttpException.php` | `HttpException` | Выбрасывается из любого места → Response::error |
|
||||||
| `src/Auth/AuthMiddleware.php` | `AuthMiddleware` | Bearer JWT → AuthContext в request |
|
| `src/Auth/AuthMiddleware.php` | `AuthMiddleware` | Bearer JWT → AuthContext в request |
|
||||||
| `src/Auth/JwtService.php` | `JwtService` | HS256, issue/verify, lcobucci/jwt |
|
| `src/Auth/JwtService.php` | `JwtService` | HS256, issue/verify, lcobucci/jwt |
|
||||||
| `src/Log/StdoutLogger.php` | `StdoutLogger` | JSON → stdout, PSR-3 |
|
| `src/Log/JsonStreamLogger.php` | `JsonStreamLogger` | JSON в любой `resource` (stdout/файл), PSR-3 |
|
||||||
| `src/Log/FileLogger.php` | `FileLogger` | JSON → файл (если LOG_FILE задан) |
|
|
||||||
| `src/Orm/AbstractMongoRepository.php` | `AbstractMongoRepository` | persist/findById/delete/findOneWhere |
|
| `src/Orm/AbstractMongoRepository.php` | `AbstractMongoRepository` | persist/findById/delete/findOneWhere |
|
||||||
| `src/Orm/MongoHydrator.php` | `MongoHydrator` | hydrate/dehydrate, поддержка Embedded/EmbeddedList |
|
| `src/Orm/MongoHydrator.php` | `MongoHydrator` | hydrate/dehydrate, поддержка Embedded/EmbeddedList |
|
||||||
| `src/ExceptionHandler.php` | `ExceptionHandler` | Ловит Throwable вне dispatch (critical лог) |
|
| `src/ExceptionHandler.php` | `ExceptionHandler` | Ловит Throwable вне dispatch (critical лог) |
|
||||||
136
docs/architecture/worker.md
Normal file
136
docs/architecture/worker.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Worker и запуск
|
||||||
|
|
||||||
|
## Entrypoint (`worker.php` приложения)
|
||||||
|
|
||||||
|
Фреймворк не привязан к конкретному рантайму. У `WorkerRunner::run()` два режима:
|
||||||
|
|
||||||
|
**1. Single-request (без аргументов).** Один запрос и выход — классический SAPI/FPM/CGI. PHP runtime сам перенаполняет `$_GET, $_POST, $_COOKIE, $_FILES, $_SERVER` и `php://input` под каждый запрос, поэтому `WorkerRunner` читает их напрямую.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$container = Kernel::boot(__DIR__);
|
||||||
|
$runner = $container->get(WorkerRunner::class);
|
||||||
|
$runner->run();
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Worker-loop (с адаптером).** Долгоживущий процесс под RoadRunner / FrankenPHP / ReactPHP / Swoole / Amphp. В этих рантаймах суперглобалы и `php://input` фиксируются на старте процесса и **не обновляются между итерациями**, поэтому `WorkerRunner` их в этом режиме не трогает: данные текущего запроса передаёт адаптер.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$container = Kernel::boot(__DIR__);
|
||||||
|
$runner = $container->get(WorkerRunner::class);
|
||||||
|
|
||||||
|
$runner->run(function ($handler) use ($rrWorker) {
|
||||||
|
$req = $rrWorker->waitRequest(); // блокирующее чтение запроса
|
||||||
|
if ($req === null) return false; // воркер просят выйти
|
||||||
|
|
||||||
|
$handler(
|
||||||
|
$req->getQuery(), // $_GET-эквивалент
|
||||||
|
$req->getPost(), // $_POST
|
||||||
|
$req->getCookies(), // $_COOKIE
|
||||||
|
$req->getUploads(), // $_FILES
|
||||||
|
$req->getServer(), // $_SERVER (REQUEST_METHOD, REQUEST_URI, заголовки HTTP_*)
|
||||||
|
$req->getBody(), // raw body, не php://input
|
||||||
|
);
|
||||||
|
|
||||||
|
return true; // продолжать цикл
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Адаптер получает `callable $handler` с сигнатурой `(array $get, array $post, array $cookie, array $files, array $server, string $rawBody): void` и возвращает `bool` — продолжать ли цикл. Лимит итераций (`worker.max_requests` из конфига, 0 = без лимита), `$app->terminate()` и `gc_collect_cycles()` отрабатывает сам `WorkerRunner` между итерациями. `WorkerRunner` резолвится через DI с фабрикой в `ContainerFactory`, забирающей лимит из `Config`.
|
||||||
|
|
||||||
|
## WorkerRunner (`src/Http/WorkerRunner.php`)
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class WorkerRunner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpApplication $app,
|
||||||
|
private readonly ExceptionHandler $exceptionHandler,
|
||||||
|
private readonly int $maxRequests = 0, // 0 = без лимита
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(?callable $loop = null): void
|
||||||
|
{
|
||||||
|
if ($loop === null) {
|
||||||
|
// SAPI: PHP runtime наполняет суперглобалы и php://input.
|
||||||
|
try {
|
||||||
|
$this->app->handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->exceptionHandler->handleException($e);
|
||||||
|
}
|
||||||
|
$this->app->terminate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->installSignalHandlers();
|
||||||
|
|
||||||
|
// Worker-loop: данные запроса приходят от адаптера, не из суперглобалов.
|
||||||
|
$handler = function (
|
||||||
|
array $get, array $post, array $cookie, array $files, array $server, string $rawBody,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$this->app->handleRequest($get, $post, $cookie, $files, $server, $rawBody);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->exceptionHandler->handleException($e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for ($n = 0; !$this->maxRequests || $n < $this->maxRequests; ++$n) {
|
||||||
|
$keepRunning = $loop($handler);
|
||||||
|
|
||||||
|
$this->app->terminate(); // хук: закрытие ресурсов (сейчас пустой)
|
||||||
|
gc_collect_cycles();
|
||||||
|
|
||||||
|
if (!$keepRunning || $this->shouldShutdown) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Graceful shutdown
|
||||||
|
|
||||||
|
В worker-loop режиме `WorkerRunner` устанавливает обработчики `SIGTERM` и `SIGINT` (`pcntl_async_signals(true)`). При получении сигнала:
|
||||||
|
|
||||||
|
1. Текущий запрос дорабатывает до конца — никаких принудительных прерываний.
|
||||||
|
2. После возврата управления из адаптера и вызова `$app->terminate()` цикл выходит, даже если адаптер хотел бы продолжать.
|
||||||
|
|
||||||
|
Это даёт оркестратору (k8s, systemd, `docker stop`) корректно завершить воркер: посылает SIGTERM, ждёт grace period, по истечении — SIGKILL. PHP не умеет безопасно прерывать выполнение посередине запроса, поэтому форсированный таймаут — забота оркестратора, не фреймворка.
|
||||||
|
|
||||||
|
В single-request режиме (`run()` без аргументов) сигналы не цепляются — там ими управляет SAPI/CGI.
|
||||||
|
|
||||||
|
Без `pcntl` экстеншена обработчики просто не ставятся — цикл работает, но без graceful shutdown. На `php:8.2-cli-alpine` (наш базовый образ) `pcntl` есть.
|
||||||
|
|
||||||
|
### Программное завершение
|
||||||
|
|
||||||
|
`WorkerRunner::requestShutdown()` — публичный метод. Эквивалент сигнала "программно". Полезен, например, для:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$runner->run(function ($handler) use ($runner, $health) {
|
||||||
|
$handler();
|
||||||
|
if ($health->isUnhealthy()) {
|
||||||
|
$runner->requestShutdown(); // выйти после следующего запроса
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Воркер читается из `Config` (ключ `worker.max_requests`); приложение само решает, мапить ли его на env-переменную:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/app.php
|
||||||
|
return [
|
||||||
|
'worker' => [
|
||||||
|
'max_requests' => (int) env('MAX_REQUESTS', 0), // 0 = без лимита
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Прочие env-переменные, на которые ссылается фреймворк:
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `APP_ENV` | `dev` | Среда исполнения (читает `Environment::detect()`) |
|
||||||
|
| `LOG_LEVEL` | `debug` | Минимальный уровень логирования |
|
||||||
|
| `LOG_FILE` | — | Путь к файлу лога (активирует второй JsonStreamLogger) |
|
||||||
|
| `JWT_SECRET` | обязателен | HMAC-ключ для JWT (≥ 32 байт) |
|
||||||
58
docs/decisions/ADR-001-embedded-orm-symmetry.md
Normal file
58
docs/decisions/ADR-001-embedded-orm-symmetry.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ADR-001: Symmetric attribute-driven (de)hydration for embedded value objects
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`MongoHydrator::dehydrate` для верхнеуровневой entity ходил через `EntityMap` и
|
||||||
|
учитывал `#[Field(name: 'x')]`, а `dehydrateEmbedded` для вложенных объектов
|
||||||
|
писал ключи напрямую через `ReflectionClass::getProperties()` — без учёта
|
||||||
|
атрибутов. В то же время `hydrate` всегда читал ключи из метаданных. Это давало
|
||||||
|
несимметрию: после `dehydrate` → `hydrate` round-trip ломался, если у embedded
|
||||||
|
свойства был `#[Field(name: 'x')]` или свойство-без-атрибутов (последнее
|
||||||
|
сохранялось, но не считывалось).
|
||||||
|
|
||||||
|
Дополнительно: `EntityMap::of()` требовал `#[Collection]` на любом классе, для
|
||||||
|
которого строились метаданные — то есть embedded-классы тоже должны были его
|
||||||
|
ставить, что бессмысленно.
|
||||||
|
|
||||||
|
Альтернативы:
|
||||||
|
|
||||||
|
1. **Оставить два пути и продублировать в `dehydrateEmbedded` обход через
|
||||||
|
`EntityMap`**. Минус: код в двух местах эволюционирует параллельно, легко
|
||||||
|
разойтись снова.
|
||||||
|
2. **Сделать `#[Collection]` опциональным в `EntityMap` и слить
|
||||||
|
`dehydrateEmbedded` в `dehydrate` через рекурсию**. Один источник истины.
|
||||||
|
3. Завести отдельный `EmbeddedMap` параллельно `EntityMap`. Дублирование
|
||||||
|
reflection-логики, тот же набор атрибутов.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Принят вариант 2.
|
||||||
|
|
||||||
|
- `#[Collection]` обязателен только для классов, реализующих `MongoEntity`.
|
||||||
|
Embedded value-objects этот интерфейс не реализуют, `EntityMap` строит для
|
||||||
|
них метаданные с `collectionName === null`.
|
||||||
|
- `MongoHydrator::dehydrate` рекурсивно вызывает себя на embedded-значениях и
|
||||||
|
на элементах embedded-list — `#[Field(name: ...)]` теперь honoured
|
||||||
|
симметрично с `hydrate`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- Round-trip embedded полей работает корректно при использовании `#[Field(name: ...)]`.
|
||||||
|
- Один путь сериализации — добавление новой логики (например, конверсии
|
||||||
|
типов) автоматически работает и для top-level, и для embedded.
|
||||||
|
|
||||||
|
**Минусы / breaking change:**
|
||||||
|
- Embedded-классы должны явно объявлять `#[Field]` на свойствах. Раньше
|
||||||
|
`dehydrateEmbedded` писал даже свойства без атрибутов — теперь они
|
||||||
|
пропускаются.
|
||||||
|
Поскольку прежнее поведение всё равно было сломано на стороне `hydrate`
|
||||||
|
(он эти свойства не читал), на практике никакой работающий код не зависел
|
||||||
|
от старого поведения.
|
||||||
|
|
||||||
|
**Связанные тесты:** `tests/Orm/MongoHydratorTest.php` —
|
||||||
|
`embeddedRoundTripUsesRenamedFieldKey`, `embeddedListRoundTrip`,
|
||||||
|
`hydrateAcceptsBSONArrayForEmbeddedList`.
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
| ADR | Название | Статус |
|
| ADR | Название | Статус |
|
||||||
|-----|----------|--------|
|
|-----|----------|--------|
|
||||||
| — | Пока нет ADR | — |
|
| [001](ADR-001-embedded-orm-symmetry.md) | Symmetric attribute-driven (de)hydration for embedded value objects | Accepted |
|
||||||
|
|
||||||
## Как писать ADR
|
## Как писать ADR
|
||||||
|
|
||||||
60
docs/development/commands.md
Normal file
60
docs/development/commands.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Commands Reference
|
||||||
|
|
||||||
|
## Запуск worker
|
||||||
|
|
||||||
|
Фреймворк запускается удобным для пользователя способом — способ запуска выбирает само приложение. Воркер-скрипт приложения подключается к фреймворку через `WorkerRunner`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// worker.php — один запрос (классический SAPI)
|
||||||
|
$runner->run();
|
||||||
|
|
||||||
|
// worker.php — worker-петля, адаптер инжектится приложением
|
||||||
|
$runner->run(fn ($handler) => /* runtime-specific request loop */);
|
||||||
|
```
|
||||||
|
|
||||||
|
Адаптер получает `callable $handler` и возвращает `bool` (продолжать ли цикл). `MAX_REQUESTS`, `terminate()` и `gc_collect_cycles()` `WorkerRunner` делает сам между итерациями.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Console-команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console <command> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Команды регистрируются в контейнере как сервисы с тегом (см. `architecture/console.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composer
|
||||||
|
|
||||||
|
На хосте PHP не установлен — composer запускается через docker-compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm --no-deps php composer install
|
||||||
|
docker compose run --rm --no-deps php composer update
|
||||||
|
docker compose run --rm --no-deps php composer dump-autoload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm --no-deps php vendor/bin/phpunit
|
||||||
|
docker compose run --rm --no-deps php vendor/bin/phpunit --testdox
|
||||||
|
docker compose run --rm --no-deps php vendor/bin/phpunit --filter MongoHydratorTest
|
||||||
|
```
|
||||||
|
|
||||||
|
PHPUnit 11, конфиг — `phpunit.xml` в корне. Тесты лежат в `tests/`, фикстуры — в `tests/Fixtures/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build php # Пересобрать PHP CLI образ
|
||||||
|
docker compose run --rm --no-deps php sh # Шелл в контейнере
|
||||||
|
```
|
||||||
|
|
||||||
|
Образ описан в `.docker/php/Dockerfile` (PHP 8.2-cli-alpine + ext-mongodb + composer). HTTP-сервиса в compose нет — только CLI для composer/phpunit.
|
||||||
23
docs/development/conventions.md
Normal file
23
docs/development/conventions.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Conventions
|
||||||
|
|
||||||
|
## PHP namespaces
|
||||||
|
|
||||||
|
| Компонент | Namespace |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Core | `Pinecore\Pinecore\` |
|
||||||
|
| HTTP | `Pinecore\Pinecore\Http\` |
|
||||||
|
| Auth | `Pinecore\Pinecore\Auth\` |
|
||||||
|
| ORM | `Pinecore\Pinecore\Orm\` |
|
||||||
|
| Log | `Pinecore\Pinecore\Log\` |
|
||||||
|
| Console | `Pinecore\Pinecore\Console\` |
|
||||||
|
| Model | `Pinecore\Pinecore\Model\` |
|
||||||
|
|
||||||
|
PSR-4: файл = имя класса, PascalCase. Атрибуты ORM — PascalCase (`#[Collection]`, `#[Field]`). Интерфейсы — суффикс `Interface`.
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
EditorConfig в корне проекта:
|
||||||
|
|
||||||
|
- PHP — UTF-8, LF, 4-space indent, 120-char line limit (PSR-12).
|
||||||
|
- JS/TS — 2-space indent, 100-char line limit.
|
||||||
|
- YAML / JSON / Docker / шаблоны — 2-space indent.
|
||||||
15
phpunit.xml
Normal file
15
phpunit.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
cacheDirectory="var/cache/phpunit"
|
||||||
|
colors="true"
|
||||||
|
failOnRisky="true"
|
||||||
|
failOnWarning="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="default">
|
||||||
|
<directory>tests</directory>
|
||||||
|
<exclude>tests/Fixtures</exclude>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Auth;
|
namespace Pinecore\Pinecore\Auth;
|
||||||
|
|
||||||
final class AuthContext
|
final class AuthContext
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Auth;
|
namespace Pinecore\Pinecore\Auth;
|
||||||
|
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Auth;
|
namespace Pinecore\Pinecore\Auth;
|
||||||
|
|
||||||
use Pronchev\Pinecore\Http\MiddlewareInterface;
|
use Pinecore\Pinecore\Http\MiddlewareInterface;
|
||||||
use Pronchev\Pinecore\Http\Request;
|
use Pinecore\Pinecore\Http\Request;
|
||||||
|
|
||||||
final class AuthMiddleware implements MiddlewareInterface
|
final class AuthMiddleware implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Auth;
|
namespace Pinecore\Pinecore\Auth;
|
||||||
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Lcobucci\JWT\Configuration;
|
use Lcobucci\JWT\Configuration;
|
||||||
@@ -8,7 +8,7 @@ use Lcobucci\JWT\Signer\Hmac\Sha256;
|
|||||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||||
use Lcobucci\JWT\Validation\Constraint\SignedWith;
|
use Lcobucci\JWT\Validation\Constraint\SignedWith;
|
||||||
use Lcobucci\JWT\Validation\Constraint\StrictValidAt;
|
use Lcobucci\JWT\Validation\Constraint\StrictValidAt;
|
||||||
use Pronchev\Pinecore\Config;
|
use Pinecore\Pinecore\Config;
|
||||||
use Psr\Clock\ClockInterface;
|
use Psr\Clock\ClockInterface;
|
||||||
|
|
||||||
final class JwtService
|
final class JwtService
|
||||||
@@ -18,18 +18,24 @@ final class JwtService
|
|||||||
|
|
||||||
public function __construct(Config $config)
|
public function __construct(Config $config)
|
||||||
{
|
{
|
||||||
$secret = $config->get('jwt.secret');
|
$secret = $config->requireString('jwt.secret');
|
||||||
if ($secret === '') {
|
if (strlen($secret) < 32) {
|
||||||
throw new \RuntimeException('JWT_SECRET is not configured');
|
throw new \RuntimeException(
|
||||||
|
'jwt.secret must be at least 32 bytes (HS256 requirement)'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->accessTtl = $config->get('jwt.access_ttl');
|
$accessTtl = $config->requireInt('jwt.access_ttl');
|
||||||
|
if ($accessTtl <= 0) {
|
||||||
|
throw new \RuntimeException('jwt.access_ttl must be a positive integer');
|
||||||
|
}
|
||||||
|
$this->accessTtl = $accessTtl;
|
||||||
|
|
||||||
$signer = new Sha256();
|
$signer = new Sha256();
|
||||||
$key = InMemory::plainText($secret);
|
$key = InMemory::plainText($secret);
|
||||||
|
|
||||||
$this->jwtConfig = Configuration::forSymmetricSigner($signer, $key);
|
$jwtConfig = Configuration::forSymmetricSigner($signer, $key);
|
||||||
$this->jwtConfig->setValidationConstraints(
|
$this->jwtConfig = $jwtConfig->withValidationConstraints(
|
||||||
new SignedWith($signer, $key),
|
new SignedWith($signer, $key),
|
||||||
new StrictValidAt(new class implements ClockInterface {
|
new StrictValidAt(new class implements ClockInterface {
|
||||||
public function now(): DateTimeImmutable
|
public function now(): DateTimeImmutable
|
||||||
@@ -45,6 +51,7 @@ final class JwtService
|
|||||||
$now = new DateTimeImmutable();
|
$now = new DateTimeImmutable();
|
||||||
$token = $this->jwtConfig->builder()
|
$token = $this->jwtConfig->builder()
|
||||||
->issuedAt($now)
|
->issuedAt($now)
|
||||||
|
->canOnlyBeUsedAfter($now)
|
||||||
->expiresAt($now->modify("+{$this->accessTtl} seconds"))
|
->expiresAt($now->modify("+{$this->accessTtl} seconds"))
|
||||||
->relatedTo($userId)
|
->relatedTo($userId)
|
||||||
->getToken($this->jwtConfig->signer(), $this->jwtConfig->signingKey());
|
->getToken($this->jwtConfig->signer(), $this->jwtConfig->signingKey());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Auth;
|
namespace Pinecore\Pinecore\Auth;
|
||||||
|
|
||||||
interface UserProviderInterface
|
interface UserProviderInterface
|
||||||
{
|
{
|
||||||
|
|||||||
114
src/Config.php
114
src/Config.php
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore;
|
namespace Pinecore\Pinecore;
|
||||||
|
|
||||||
class Config
|
class Config
|
||||||
{
|
{
|
||||||
@@ -21,19 +21,115 @@ class Config
|
|||||||
|
|
||||||
public function get(string $key, mixed $default = null): mixed
|
public function get(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
$parts = explode('.', $key);
|
return $this->find($key, $found) ? $found : $default;
|
||||||
$value = $this->data;
|
|
||||||
|
|
||||||
foreach ($parts as $part) {
|
|
||||||
if (!is_array($value) || !array_key_exists($part, $value)) {
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
$value = $value[$part];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function requireString(string $key): string
|
||||||
|
{
|
||||||
|
$value = $this->requireKey($key);
|
||||||
|
return is_string($value) ? $value : $this->typeError($key, 'string', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requireInt(string $key): int
|
||||||
|
{
|
||||||
|
$value = $this->requireKey($key);
|
||||||
|
return is_int($value) ? $value : $this->typeError($key, 'int', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requireBool(string $key): bool
|
||||||
|
{
|
||||||
|
$value = $this->requireKey($key);
|
||||||
|
return is_bool($value) ? $value : $this->typeError($key, 'bool', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int|string, mixed> */
|
||||||
|
public function requireArray(string $key): array
|
||||||
|
{
|
||||||
|
$value = $this->requireKey($key);
|
||||||
|
return is_array($value) ? $value : $this->typeError($key, 'array', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function requireStringList(string $key): array
|
||||||
|
{
|
||||||
|
$value = $this->requireArray($key);
|
||||||
|
if (array_values($value) !== $value) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"Config: key \"{$key}\" must be a list of strings, got associative array"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
foreach ($value as $i => $item) {
|
||||||
|
if (!is_string($item)) {
|
||||||
|
$type = get_debug_type($item);
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"Config: key \"{$key}\" must be a list of strings, got {$type} at index {$i}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getString(string $key, string $default): string
|
||||||
|
{
|
||||||
|
if (!$this->find($key, $value)) return $default;
|
||||||
|
return is_string($value) ? $value : $this->typeError($key, 'string', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInt(string $key, int $default): int
|
||||||
|
{
|
||||||
|
if (!$this->find($key, $value)) return $default;
|
||||||
|
return is_int($value) ? $value : $this->typeError($key, 'int', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBool(string $key, bool $default): bool
|
||||||
|
{
|
||||||
|
if (!$this->find($key, $value)) return $default;
|
||||||
|
return is_bool($value) ? $value : $this->typeError($key, 'bool', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string, mixed> $default
|
||||||
|
* @return array<int|string, mixed>
|
||||||
|
*/
|
||||||
|
public function getArray(string $key, array $default): array
|
||||||
|
{
|
||||||
|
if (!$this->find($key, $value)) return $default;
|
||||||
|
return is_array($value) ? $value : $this->typeError($key, 'array', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-lookup by dot-notation. Returns true if the key exists; the value
|
||||||
|
* is written to $value (out-param). Returns false if any path segment
|
||||||
|
* is missing.
|
||||||
|
*/
|
||||||
|
private function find(string $key, mixed &$value): bool
|
||||||
|
{
|
||||||
|
$parts = explode('.', $key);
|
||||||
|
$cursor = $this->data;
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if (!is_array($cursor) || !array_key_exists($part, $cursor)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$cursor = $cursor[$part];
|
||||||
|
}
|
||||||
|
$value = $cursor;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireKey(string $key): mixed
|
||||||
|
{
|
||||||
|
if (!$this->find($key, $value)) {
|
||||||
|
throw new \RuntimeException("Config: missing required key \"{$key}\"");
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function typeError(string $key, string $expected, mixed $actual): never
|
||||||
|
{
|
||||||
|
$type = get_debug_type($actual);
|
||||||
|
throw new \RuntimeException("Config: key \"{$key}\" must be {$expected}, got {$type}");
|
||||||
|
}
|
||||||
|
|
||||||
private static function loadDir(string $dir): array
|
private static function loadDir(string $dir): array
|
||||||
{
|
{
|
||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir)) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Console;
|
namespace Pinecore\Pinecore\Console;
|
||||||
|
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
@@ -13,13 +13,30 @@ final class ConsoleApplication
|
|||||||
|
|
||||||
public function run(array $argv): int
|
public function run(array $argv): int
|
||||||
{
|
{
|
||||||
$commandStr = $argv[1] ?? null;
|
if (!isset($argv[1]) || $argv[1] === 'help') {
|
||||||
|
|
||||||
if ($commandStr === null || $commandStr === 'help') {
|
|
||||||
$this->printHelp($argv[2] ?? null);
|
$this->printHelp($argv[2] ?? null);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split argv (after script name) into positional command tokens and option tokens.
|
||||||
|
// Positional tokens form the command string matched against command signatures
|
||||||
|
// (e.g. "users:create admin"). Tokens from the first "--" onwards are options.
|
||||||
|
$positional = [];
|
||||||
|
$optionTokens = [];
|
||||||
|
$inOptions = false;
|
||||||
|
for ($i = 1, $n = count($argv); $i < $n; $i++) {
|
||||||
|
$tok = $argv[$i];
|
||||||
|
if (!$inOptions && str_starts_with($tok, '--')) {
|
||||||
|
$inOptions = true;
|
||||||
|
}
|
||||||
|
if ($inOptions) {
|
||||||
|
$optionTokens[] = $tok;
|
||||||
|
} else {
|
||||||
|
$positional[] = $tok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$commandStr = implode(' ', $positional);
|
||||||
$match = $this->router->match($commandStr);
|
$match = $this->router->match($commandStr);
|
||||||
|
|
||||||
if (!$match->found) {
|
if (!$match->found) {
|
||||||
@@ -31,7 +48,7 @@ final class ConsoleApplication
|
|||||||
$input = ConsoleInput::parse(
|
$input = ConsoleInput::parse(
|
||||||
$commandStr,
|
$commandStr,
|
||||||
$match->pathParams,
|
$match->pathParams,
|
||||||
array_slice($argv, 2),
|
$optionTokens,
|
||||||
$match->definition->options,
|
$match->definition->options,
|
||||||
);
|
);
|
||||||
$output = new ConsoleOutput();
|
$output = new ConsoleOutput();
|
||||||
@@ -51,6 +68,11 @@ final class ConsoleApplication
|
|||||||
|
|
||||||
$commands = $this->router->all();
|
$commands = $this->router->all();
|
||||||
|
|
||||||
|
if ($commands === []) {
|
||||||
|
echo 'No commands registered.' . PHP_EOL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
echo 'Available commands:' . PHP_EOL . PHP_EOL;
|
echo 'Available commands:' . PHP_EOL . PHP_EOL;
|
||||||
|
|
||||||
$maxLen = max(array_map(static fn($c) => strlen($c->signature), $commands));
|
$maxLen = max(array_map(static fn($c) => strlen($c->signature), $commands));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Console;
|
namespace Pinecore\Pinecore\Console;
|
||||||
|
|
||||||
final class ConsoleDefinition
|
final class ConsoleDefinition
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Console;
|
namespace Pinecore\Pinecore\Console;
|
||||||
|
|
||||||
final class ConsoleInput
|
final class ConsoleInput
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Console;
|
namespace Pinecore\Pinecore\Console;
|
||||||
|
|
||||||
final class ConsoleMatch
|
final class ConsoleMatch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Console;
|
namespace Pinecore\Pinecore\Console;
|
||||||
|
|
||||||
final class ConsoleOutput
|
final class ConsoleOutput
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Console;
|
namespace Pinecore\Pinecore\Console;
|
||||||
|
|
||||||
final class ConsoleRouter
|
final class ConsoleRouter
|
||||||
{
|
{
|
||||||
@@ -45,14 +45,16 @@ final class ConsoleRouter
|
|||||||
private function compile(ConsoleDefinition $command): array
|
private function compile(ConsoleDefinition $command): array
|
||||||
{
|
{
|
||||||
$params = [];
|
$params = [];
|
||||||
// Escape dots, then replace {param} with named capture groups
|
|
||||||
$pattern = preg_replace_callback(
|
$pattern = preg_replace_callback(
|
||||||
'/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/',
|
'/\{([a-zA-Z_][a-zA-Z0-9_]*)\}|([^{]+)/',
|
||||||
static function (array $m) use (&$params): string {
|
static function (array $m) use (&$params): string {
|
||||||
|
if (($m[1] ?? '') !== '') {
|
||||||
$params[] = $m[1];
|
$params[] = $m[1];
|
||||||
return '(?P<' . $m[1] . '>[^.:]+)';
|
return '(?P<' . $m[1] . '>[^.:]+)';
|
||||||
|
}
|
||||||
|
return preg_quote($m[2], '#');
|
||||||
},
|
},
|
||||||
str_replace('.', '\.', $command->signature),
|
$command->signature,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Console;
|
namespace Pinecore\Pinecore\Console;
|
||||||
|
|
||||||
final class OptionDefinition
|
final class OptionDefinition
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore;
|
namespace Pinecore\Pinecore;
|
||||||
|
|
||||||
use DI\Container;
|
use DI\Container;
|
||||||
use DI\ContainerBuilder;
|
use DI\ContainerBuilder;
|
||||||
use Pronchev\Pinecore\Http\Router;
|
use Pinecore\Pinecore\Console\ConsoleRouter;
|
||||||
|
use Pinecore\Pinecore\Http\HttpApplication;
|
||||||
|
use Pinecore\Pinecore\Http\Router;
|
||||||
|
use Pinecore\Pinecore\Http\WorkerRunner;
|
||||||
|
use Pinecore\Pinecore\Log\CompositeLogger;
|
||||||
|
use Pinecore\Pinecore\Log\JsonStreamLogger;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
class ContainerFactory
|
class ContainerFactory
|
||||||
{
|
{
|
||||||
@@ -20,7 +26,33 @@ class ContainerFactory
|
|||||||
$builder->enableCompilation($cacheDir);
|
$builder->enableCompilation($cacheDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder->useAutowiring(true);
|
$builder->useAttributes(true);
|
||||||
|
|
||||||
|
$builder->addDefinitions([
|
||||||
|
Config::class => $config,
|
||||||
|
Environment::class => $env,
|
||||||
|
LoggerInterface::class => function () use ($config) {
|
||||||
|
$level = JsonStreamLogger::levelValue($config->getString('log.level', 'debug'));
|
||||||
|
$channel = $config->getString('log.channel', 'app');
|
||||||
|
|
||||||
|
$loggers = [
|
||||||
|
new JsonStreamLogger(self::openStream('php://stdout', 'w'), $level, $channel),
|
||||||
|
];
|
||||||
|
|
||||||
|
$logFile = $config->getString('log.file', '');
|
||||||
|
if ($logFile !== '') {
|
||||||
|
$loggers[] = new JsonStreamLogger(self::openStream($logFile, 'a'), $level, $channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count($loggers) === 1 ? $loggers[0] : new CompositeLogger($loggers);
|
||||||
|
},
|
||||||
|
WorkerRunner::class => fn($c) => new WorkerRunner(
|
||||||
|
$c->get(HttpApplication::class),
|
||||||
|
$c->get(ExceptionHandler::class),
|
||||||
|
$config->getInt('worker.max_requests', 0),
|
||||||
|
),
|
||||||
|
ConsoleRouter::class => fn() => new ConsoleRouter([]),
|
||||||
|
]);
|
||||||
|
|
||||||
$routesFile = $basePath . '/config/routes.php';
|
$routesFile = $basePath . '/config/routes.php';
|
||||||
if (file_exists($routesFile)) {
|
if (file_exists($routesFile)) {
|
||||||
@@ -30,6 +62,14 @@ class ContainerFactory
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$commandsFile = $basePath . '/config/commands.php';
|
||||||
|
if (file_exists($commandsFile)) {
|
||||||
|
$commands = require $commandsFile;
|
||||||
|
$builder->addDefinitions([
|
||||||
|
ConsoleRouter::class => fn() => new ConsoleRouter($commands),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$servicesFile = $basePath . '/config/services.php';
|
$servicesFile = $basePath . '/config/services.php';
|
||||||
if (file_exists($servicesFile)) {
|
if (file_exists($servicesFile)) {
|
||||||
$definitions = require $servicesFile;
|
$definitions = require $servicesFile;
|
||||||
@@ -38,4 +78,14 @@ class ContainerFactory
|
|||||||
|
|
||||||
return $builder->build();
|
return $builder->build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return resource */
|
||||||
|
private static function openStream(string $path, string $mode): mixed
|
||||||
|
{
|
||||||
|
$stream = @fopen($path, $mode);
|
||||||
|
if ($stream === false) {
|
||||||
|
throw new \RuntimeException("Failed to open log stream: {$path}");
|
||||||
|
}
|
||||||
|
return $stream;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore;
|
namespace Pinecore\Pinecore;
|
||||||
|
|
||||||
class Environment
|
class Environment
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore;
|
namespace Pinecore\Pinecore;
|
||||||
|
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
|||||||
105
src/Http/CorsPolicy.php
Normal file
105
src/Http/CorsPolicy.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pinecore\Pinecore\Http;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Config;
|
||||||
|
|
||||||
|
final class CorsPolicy
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private readonly array $allowedOrigins;
|
||||||
|
/** @var list<string> */
|
||||||
|
private readonly array $allowedMethods;
|
||||||
|
/** @var list<string> */
|
||||||
|
private readonly array $allowedHeaders;
|
||||||
|
private readonly bool $allowCredentials;
|
||||||
|
private readonly int $maxAge;
|
||||||
|
private readonly bool $isWildcardOnly;
|
||||||
|
|
||||||
|
public function __construct(Config $config)
|
||||||
|
{
|
||||||
|
// Touch the section first for a single clear "missing app.cors" error
|
||||||
|
// instead of five separate complaints about each sub-key.
|
||||||
|
$config->requireArray('app.cors');
|
||||||
|
|
||||||
|
$this->allowedOrigins = $config->requireStringList('app.cors.allowed_origins');
|
||||||
|
$this->allowedMethods = $config->requireStringList('app.cors.allowed_methods');
|
||||||
|
$this->allowedHeaders = $config->requireStringList('app.cors.allowed_headers');
|
||||||
|
$this->allowCredentials = $config->getBool('app.cors.allow_credentials', false);
|
||||||
|
$this->maxAge = $config->getInt('app.cors.max_age', 600);
|
||||||
|
|
||||||
|
$this->isWildcardOnly = $this->allowedOrigins === ['*'];
|
||||||
|
|
||||||
|
if ($this->isWildcardOnly && $this->allowCredentials) {
|
||||||
|
// Per Fetch Standard: Access-Control-Allow-Origin: * is incompatible
|
||||||
|
// with Access-Control-Allow-Credentials: true. Browsers reject the
|
||||||
|
// combination. Fail at boot, not silently in prod.
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'app.cors: allowed_origins=["*"] cannot be combined with allow_credentials=true. '
|
||||||
|
. 'List explicit origins instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers attached to a normal (non-OPTIONS) response.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function simpleHeaders(?string $requestOrigin): array
|
||||||
|
{
|
||||||
|
$allowOrigin = $this->resolveOrigin($requestOrigin);
|
||||||
|
|
||||||
|
$headers = [];
|
||||||
|
if ($allowOrigin !== null) {
|
||||||
|
$headers['Access-Control-Allow-Origin'] = $allowOrigin;
|
||||||
|
if ($this->allowCredentials) {
|
||||||
|
$headers['Access-Control-Allow-Credentials'] = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$this->isWildcardOnly) {
|
||||||
|
$headers['Vary'] = 'Origin';
|
||||||
|
}
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers for an OPTIONS preflight response. When the origin is rejected,
|
||||||
|
* returns an empty array (or just Vary) — the browser treats the missing
|
||||||
|
* Allow-Origin as a default-deny.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function preflightHeaders(?string $requestOrigin): array
|
||||||
|
{
|
||||||
|
$allowOrigin = $this->resolveOrigin($requestOrigin);
|
||||||
|
|
||||||
|
$headers = [];
|
||||||
|
if ($allowOrigin !== null) {
|
||||||
|
$headers['Access-Control-Allow-Origin'] = $allowOrigin;
|
||||||
|
$headers['Access-Control-Allow-Methods'] = implode(', ', $this->allowedMethods);
|
||||||
|
$headers['Access-Control-Allow-Headers'] = implode(', ', $this->allowedHeaders);
|
||||||
|
$headers['Access-Control-Max-Age'] = (string) $this->maxAge;
|
||||||
|
if ($this->allowCredentials) {
|
||||||
|
$headers['Access-Control-Allow-Credentials'] = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$this->isWildcardOnly) {
|
||||||
|
$headers['Vary'] = 'Origin';
|
||||||
|
}
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOrigin(?string $requestOrigin): ?string
|
||||||
|
{
|
||||||
|
if ($this->isWildcardOnly) {
|
||||||
|
// Already validated incompatibility with credentials in the ctor.
|
||||||
|
return '*';
|
||||||
|
}
|
||||||
|
if ($requestOrigin === null || $requestOrigin === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return in_array($requestOrigin, $this->allowedOrigins, true) ? $requestOrigin : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,42 +1,93 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Http;
|
namespace Pinecore\Pinecore\Http;
|
||||||
|
|
||||||
use Pronchev\Pinecore\Auth\AuthException;
|
use Pinecore\Pinecore\Auth\AuthException;
|
||||||
use Pronchev\Pinecore\Config;
|
use Pinecore\Pinecore\Config;
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
final class HttpApplication
|
class HttpApplication
|
||||||
{
|
{
|
||||||
|
private const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
private readonly int $maxBodyBytes;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ContainerInterface $container,
|
private readonly ContainerInterface $container,
|
||||||
private readonly Router $router,
|
private readonly Router $router,
|
||||||
private readonly MiddlewarePipeline $pipeline,
|
private readonly MiddlewarePipeline $pipeline,
|
||||||
private readonly Config $config,
|
private readonly Config $config,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {}
|
private readonly CorsPolicy $cors,
|
||||||
|
) {
|
||||||
|
$this->maxBodyBytes = $config->getInt('http.max_body_bytes', self::DEFAULT_MAX_BODY_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
public function handleRequest(array $get, array $post, array $cookie, array $files, array $server): void
|
public function handleRequest(
|
||||||
{
|
array $get,
|
||||||
$rawBody = (string) file_get_contents('php://input');
|
array $post,
|
||||||
$request = Request::fromGlobals($get, $cookie, $files, $server, $rawBody);
|
array $cookie,
|
||||||
|
array $files,
|
||||||
|
array $server,
|
||||||
|
?string $rawBody = null,
|
||||||
|
): void {
|
||||||
|
$method = strtoupper($server['REQUEST_METHOD'] ?? 'GET');
|
||||||
|
$origin = isset($server['HTTP_ORIGIN']) ? (string) $server['HTTP_ORIGIN'] : null;
|
||||||
|
|
||||||
// OPTIONS preflight — respond immediately with CORS headers, no routing
|
// OPTIONS preflight — respond immediately with CORS headers, no routing or body parsing.
|
||||||
if ($request->method === 'OPTIONS') {
|
if ($method === 'OPTIONS') {
|
||||||
$this->emitCorsHeaders();
|
foreach ($this->cors->preflightHeaders($origin) as $name => $value) {
|
||||||
|
$this->sendHeader($name . ': ' . $value);
|
||||||
|
}
|
||||||
http_response_code(204);
|
http_response_code(204);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $this->dispatch($request);
|
$corsHeaders = $this->cors->simpleHeaders($origin);
|
||||||
$response = $response->withHeaders($this->corsHeaders());
|
|
||||||
|
$declaredLength = (int) ($server['CONTENT_LENGTH'] ?? 0);
|
||||||
|
if ($this->maxBodyBytes > 0 && $declaredLength > $this->maxBodyBytes) {
|
||||||
|
Response::error('Payload Too Large', 413)
|
||||||
|
->withHeaders($corsHeaders)
|
||||||
|
->emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawBody ??= $this->readRawBody();
|
||||||
|
if ($this->maxBodyBytes > 0 && strlen($rawBody) > $this->maxBodyBytes) {
|
||||||
|
Response::error('Payload Too Large', 413)
|
||||||
|
->withHeaders($corsHeaders)
|
||||||
|
->emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->dispatch($get, $post, $cookie, $files, $server, $rawBody);
|
||||||
|
$response = $response->withHeaders($corsHeaders);
|
||||||
$response->emit();
|
$response->emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dispatch(Request $request): Response
|
protected function readRawBody(): string
|
||||||
{
|
{
|
||||||
|
return (string) file_get_contents('php://input');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for emitting raw header lines (preflight path). Tests override
|
||||||
|
* this to capture headers — `header()` is not observable in CLI.
|
||||||
|
*/
|
||||||
|
protected function sendHeader(string $line): void
|
||||||
|
{
|
||||||
|
header($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dispatch(
|
||||||
|
array $get, array $post, array $cookie, array $files, array $server, string $rawBody
|
||||||
|
): Response {
|
||||||
|
$request = null;
|
||||||
try {
|
try {
|
||||||
|
$request = Request::fromGlobals($get, $post, $cookie, $files, $server, $rawBody);
|
||||||
|
|
||||||
$match = $this->router->match($request->method, $request->path);
|
$match = $this->router->match($request->method, $request->path);
|
||||||
|
|
||||||
if ($match->methodNotAllowed) {
|
if ($match->methodNotAllowed) {
|
||||||
@@ -58,13 +109,11 @@ final class HttpApplication
|
|||||||
return Response::error($e->getMessage(), 401);
|
return Response::error($e->getMessage(), 401);
|
||||||
} catch (HttpException $e) {
|
} catch (HttpException $e) {
|
||||||
return Response::error($e->getMessage(), $e->getCode());
|
return Response::error($e->getMessage(), $e->getCode());
|
||||||
} catch (\JsonException) {
|
|
||||||
return Response::error('Invalid JSON body', 400);
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('Unhandled exception during dispatch', [
|
$this->logger->error('Unhandled exception during dispatch', [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
'method' => $request->method,
|
'method' => $request?->method ?? ($server['REQUEST_METHOD'] ?? 'UNKNOWN'),
|
||||||
'path' => $request->path,
|
'path' => $request?->path ?? ($server['REQUEST_URI'] ?? 'UNKNOWN'),
|
||||||
]);
|
]);
|
||||||
return Response::error('Internal Server Error', 500);
|
return Response::error('Internal Server Error', 500);
|
||||||
}
|
}
|
||||||
@@ -74,34 +123,4 @@ final class HttpApplication
|
|||||||
{
|
{
|
||||||
// Завершение работы приложения. Закрытие соединений, ресурсов и.т.п.
|
// Завершение работы приложения. Закрытие соединений, ресурсов и.т.п.
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, string> */
|
|
||||||
private function corsHeaders(): array
|
|
||||||
{
|
|
||||||
$cors = $this->corsConfig();
|
|
||||||
return [
|
|
||||||
'Access-Control-Allow-Origin' => $cors['origins'],
|
|
||||||
'Access-Control-Allow-Methods' => $cors['methods'],
|
|
||||||
'Access-Control-Allow-Headers' => $cors['headers'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function emitCorsHeaders(): void
|
|
||||||
{
|
|
||||||
$cors = $this->corsConfig();
|
|
||||||
header('Access-Control-Allow-Origin: ' . $cors['origins']);
|
|
||||||
header('Access-Control-Allow-Methods: ' . $cors['methods']);
|
|
||||||
header('Access-Control-Allow-Headers: ' . $cors['headers']);
|
|
||||||
header('Access-Control-Max-Age: ' . $cors['max_age']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
private function corsConfig(): array
|
|
||||||
{
|
|
||||||
$cors = $this->config->get('app.cors');
|
|
||||||
if (!is_array($cors)) {
|
|
||||||
throw new \RuntimeException('app.cors config is missing');
|
|
||||||
}
|
|
||||||
return $cors;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Http;
|
namespace Pinecore\Pinecore\Http;
|
||||||
|
|
||||||
final class HttpException extends \RuntimeException
|
final class HttpException extends \RuntimeException
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Http;
|
namespace Pinecore\Pinecore\Http;
|
||||||
|
|
||||||
interface MiddlewareInterface
|
interface MiddlewareInterface
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Http;
|
namespace Pinecore\Pinecore\Http;
|
||||||
|
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Http;
|
namespace Pinecore\Pinecore\Http;
|
||||||
|
|
||||||
final class Request
|
final class Request
|
||||||
{
|
{
|
||||||
@@ -8,7 +8,7 @@ final class Request
|
|||||||
* @param array<string, string> $query
|
* @param array<string, string> $query
|
||||||
* @param array<string, string> $pathParams
|
* @param array<string, string> $pathParams
|
||||||
* @param array<string, string> $headers lowercase-hyphen keys (e.g. 'content-type', 'authorization')
|
* @param array<string, string> $headers lowercase-hyphen keys (e.g. 'content-type', 'authorization')
|
||||||
* @param array<string, mixed> $body decoded JSON body
|
* @param array<array-key, mixed> $body parsed body (JSON object/list, form fields, or [])
|
||||||
* @param array<string, string> $cookies $_COOKIE
|
* @param array<string, string> $cookies $_COOKIE
|
||||||
* @param array<string, mixed> $files $_FILES
|
* @param array<string, mixed> $files $_FILES
|
||||||
* @param array<string, mixed> $context middleware-injected values (e.g. AuthContext)
|
* @param array<string, mixed> $context middleware-injected values (e.g. AuthContext)
|
||||||
@@ -25,11 +25,17 @@ final class Request
|
|||||||
private array $context = [],
|
private array $context = [],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** @return array<array-key, mixed> */
|
||||||
public function body(): array
|
public function body(): array
|
||||||
{
|
{
|
||||||
return $this->body;
|
return $this->body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function input(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $this->body[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
public function get(string $key, mixed $default = null): mixed
|
public function get(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
return $this->context[$key] ?? $default;
|
return $this->context[$key] ?? $default;
|
||||||
@@ -58,22 +64,61 @@ final class Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static function fromGlobals(
|
public static function fromGlobals(
|
||||||
array $get, array $cookies, array $files, array $server, string $rawBody
|
array $get, array $post, array $cookies, array $files, array $server, string $rawBody
|
||||||
): self {
|
): self {
|
||||||
$method = strtoupper($server['REQUEST_METHOD'] ?? 'GET');
|
$method = strtoupper($server['REQUEST_METHOD'] ?? 'GET');
|
||||||
$uri = $server['REQUEST_URI'] ?? '/';
|
$uri = $server['REQUEST_URI'] ?? '/';
|
||||||
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||||
$headers = self::extractHeaders($server);
|
$headers = self::extractHeaders($server);
|
||||||
|
|
||||||
$body = [];
|
$body = self::parseBody($method, $headers['content-type'] ?? '', $post, $rawBody);
|
||||||
$contentType = $headers['content-type'] ?? '';
|
|
||||||
if (str_contains($contentType, 'application/json') && $rawBody !== '') {
|
|
||||||
$body = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self($method, $path, $get, [], $headers, $body, $cookies, $files);
|
return new self($method, $path, $get, [], $headers, $body, $cookies, $files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $post
|
||||||
|
* @return array<array-key, mixed>
|
||||||
|
*/
|
||||||
|
private static function parseBody(string $method, string $contentType, array $post, string $rawBody): array
|
||||||
|
{
|
||||||
|
if ($contentType === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($contentType, 'application/json')) {
|
||||||
|
if ($rawBody === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$decoded = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException) {
|
||||||
|
throw new HttpException('Invalid JSON body', 400);
|
||||||
|
}
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new HttpException('JSON body must be an object or array', 400);
|
||||||
|
}
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($contentType, 'multipart/form-data')) {
|
||||||
|
return $post;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($contentType, 'application/x-www-form-urlencoded')) {
|
||||||
|
if ($method === 'POST') {
|
||||||
|
return $post;
|
||||||
|
}
|
||||||
|
if ($rawBody === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
parse_str($rawBody, $parsed);
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, string> */
|
/** @return array<string, string> */
|
||||||
private static function extractHeaders(array $server): array
|
private static function extractHeaders(array $server): array
|
||||||
{
|
{
|
||||||
@@ -88,6 +133,9 @@ final class Request
|
|||||||
if (isset($server['CONTENT_TYPE'])) {
|
if (isset($server['CONTENT_TYPE'])) {
|
||||||
$headers['content-type'] = $server['CONTENT_TYPE'];
|
$headers['content-type'] = $server['CONTENT_TYPE'];
|
||||||
}
|
}
|
||||||
|
if (isset($server['CONTENT_LENGTH'])) {
|
||||||
|
$headers['content-length'] = (string) $server['CONTENT_LENGTH'];
|
||||||
|
}
|
||||||
return $headers;
|
return $headers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Http;
|
namespace Pinecore\Pinecore\Http;
|
||||||
|
|
||||||
final class Response
|
final class Response
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Http;
|
namespace Pinecore\Pinecore\Http;
|
||||||
|
|
||||||
final class RouteDefinition
|
final class RouteDefinition
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Http;
|
namespace Pinecore\Pinecore\Http;
|
||||||
|
|
||||||
final class RouteMatch
|
final class RouteMatch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Http;
|
namespace Pinecore\Pinecore\Http;
|
||||||
|
|
||||||
final class Router
|
final class Router
|
||||||
{
|
{
|
||||||
@@ -54,10 +54,13 @@ final class Router
|
|||||||
{
|
{
|
||||||
$params = [];
|
$params = [];
|
||||||
$pattern = preg_replace_callback(
|
$pattern = preg_replace_callback(
|
||||||
'/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/',
|
'/\{([a-zA-Z_][a-zA-Z0-9_]*)\}|([^{]+)/',
|
||||||
static function (array $m) use (&$params): string {
|
static function (array $m) use (&$params): string {
|
||||||
|
if (($m[1] ?? '') !== '') {
|
||||||
$params[] = $m[1];
|
$params[] = $m[1];
|
||||||
return '(?P<' . $m[1] . '>[^/]+)';
|
return '(?P<' . $m[1] . '>[^/]+)';
|
||||||
|
}
|
||||||
|
return preg_quote($m[2], '#');
|
||||||
},
|
},
|
||||||
$route->path,
|
$route->path,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,35 +1,82 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Http;
|
namespace Pinecore\Pinecore\Http;
|
||||||
|
|
||||||
use Pronchev\Pinecore\ExceptionHandler;
|
use Pinecore\Pinecore\ExceptionHandler;
|
||||||
|
|
||||||
final class WorkerRunner
|
final class WorkerRunner
|
||||||
{
|
{
|
||||||
|
private bool $shouldShutdown = false;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly HttpApplication $app,
|
private readonly HttpApplication $app,
|
||||||
private readonly ExceptionHandler $exceptionHandler,
|
private readonly ExceptionHandler $exceptionHandler,
|
||||||
|
private readonly int $maxRequests = 0,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function run(): void
|
public function run(?callable $loop = null): void
|
||||||
{
|
{
|
||||||
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0);
|
if ($loop === null) {
|
||||||
|
// SAPI/CGI/FPM single-request mode: PHP runtime populates the
|
||||||
for ($n = 0; !$maxRequests || $n < $maxRequests; ++$n) {
|
// superglobals and php://input for us.
|
||||||
$keepRunning = frankenphp_handle_request(function (): void {
|
|
||||||
try {
|
try {
|
||||||
$this->app->handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
|
$this->app->handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->exceptionHandler->handleException($e);
|
$this->exceptionHandler->handleException($e);
|
||||||
}
|
}
|
||||||
});
|
$this->app->terminate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->installSignalHandlers();
|
||||||
|
|
||||||
|
// Worker-loop mode: the adapter sources per-iteration request data
|
||||||
|
// from its runtime (RoadRunner, FrankenPHP, ReactPHP, ...) and feeds
|
||||||
|
// it to the handler. WorkerRunner does not touch superglobals here —
|
||||||
|
// they are not repopulated between iterations in worker runtimes.
|
||||||
|
$handler = function (
|
||||||
|
array $get,
|
||||||
|
array $post,
|
||||||
|
array $cookie,
|
||||||
|
array $files,
|
||||||
|
array $server,
|
||||||
|
string $rawBody,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$this->app->handleRequest($get, $post, $cookie, $files, $server, $rawBody);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->exceptionHandler->handleException($e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for ($n = 0; !$this->maxRequests || $n < $this->maxRequests; ++$n) {
|
||||||
|
$keepRunning = $loop($handler);
|
||||||
|
|
||||||
$this->app->terminate();
|
$this->app->terminate();
|
||||||
gc_collect_cycles();
|
gc_collect_cycles();
|
||||||
|
|
||||||
if (!$keepRunning) {
|
if (!$keepRunning || $this->shouldShutdown) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programmatically request the worker loop to exit after the current
|
||||||
|
* iteration completes. Also called from SIGTERM/SIGINT handlers.
|
||||||
|
*/
|
||||||
|
public function requestShutdown(): void
|
||||||
|
{
|
||||||
|
$this->shouldShutdown = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function installSignalHandlers(): void
|
||||||
|
{
|
||||||
|
if (!function_exists('pcntl_async_signals')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pcntl_async_signals(true);
|
||||||
|
pcntl_signal(SIGTERM, fn () => $this->requestShutdown());
|
||||||
|
pcntl_signal(SIGINT, fn () => $this->requestShutdown());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore;
|
namespace Pinecore\Pinecore;
|
||||||
|
|
||||||
use DI\Container;
|
use DI\Container;
|
||||||
|
|
||||||
class Kernel
|
final class Kernel
|
||||||
{
|
{
|
||||||
private static ?Container $container = null;
|
|
||||||
|
|
||||||
public static function boot(string $basePath): Container
|
public static function boot(string $basePath): Container
|
||||||
{
|
{
|
||||||
$env = Environment::detect();
|
$env = Environment::detect();
|
||||||
$config = Config::load($basePath . '/config', $env);
|
$config = Config::load($basePath . '/config', $env);
|
||||||
$container = ContainerFactory::build($env, $config, $basePath);
|
return ContainerFactory::build($env, $config, $basePath);
|
||||||
|
|
||||||
self::$container = $container;
|
|
||||||
|
|
||||||
return $container;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function container(): Container
|
|
||||||
{
|
|
||||||
if (self::$container === null) {
|
|
||||||
throw new \RuntimeException('Kernel has not been booted. Call Kernel::boot() first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::$container;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Log;
|
namespace Pinecore\Pinecore\Log;
|
||||||
|
|
||||||
use Psr\Log\AbstractLogger;
|
use Psr\Log\AbstractLogger;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Log;
|
|
||||||
|
|
||||||
use Pronchev\Pinecore\Config;
|
|
||||||
use Psr\Log\AbstractLogger;
|
|
||||||
use Psr\Log\LogLevel;
|
|
||||||
|
|
||||||
final class FileLogger extends AbstractLogger
|
|
||||||
{
|
|
||||||
private readonly int $minLevel;
|
|
||||||
private readonly string $channel;
|
|
||||||
/** @var resource */
|
|
||||||
private readonly mixed $handle;
|
|
||||||
|
|
||||||
public function __construct(Config $config)
|
|
||||||
{
|
|
||||||
$this->minLevel = self::levelValue($config->get('log.level', 'debug'));
|
|
||||||
$this->channel = $config->get('log.channel', 'app');
|
|
||||||
$this->handle = fopen($config->get('log.file'), 'a');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function log(mixed $level, string|\Stringable $message, array $context = []): void
|
|
||||||
{
|
|
||||||
if (self::levelValue((string) $level) < $this->minLevel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$entry = [
|
|
||||||
'ts' => (new \DateTimeImmutable())->format('Y-m-d\TH:i:s.v\Z'),
|
|
||||||
'level' => $level,
|
|
||||||
'channel' => $this->channel,
|
|
||||||
'message' => self::interpolate((string) $message, $context),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($context !== []) {
|
|
||||||
$entry['context'] = self::serializeContext($context);
|
|
||||||
}
|
|
||||||
|
|
||||||
fwrite($this->handle, json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function interpolate(string $message, array $context): string
|
|
||||||
{
|
|
||||||
$replacements = [];
|
|
||||||
foreach ($context as $key => $value) {
|
|
||||||
if (!is_array($value) && (!is_object($value) || method_exists($value, '__toString'))) {
|
|
||||||
$replacements['{' . $key . '}'] = (string) $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strtr($message, $replacements);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function serializeContext(array $context): array
|
|
||||||
{
|
|
||||||
$result = [];
|
|
||||||
foreach ($context as $key => $value) {
|
|
||||||
$result[$key] = $value instanceof \Throwable ? self::serializeThrowable($value) : $value;
|
|
||||||
}
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function serializeThrowable(\Throwable $e): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'class' => $e::class,
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
'file' => $e->getFile(),
|
|
||||||
'line' => $e->getLine(),
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function levelValue(string $level): int
|
|
||||||
{
|
|
||||||
return match ($level) {
|
|
||||||
LogLevel::DEBUG => 0,
|
|
||||||
LogLevel::INFO => 1,
|
|
||||||
LogLevel::NOTICE => 2,
|
|
||||||
LogLevel::WARNING => 3,
|
|
||||||
LogLevel::ERROR => 4,
|
|
||||||
LogLevel::CRITICAL => 5,
|
|
||||||
LogLevel::ALERT => 6,
|
|
||||||
LogLevel::EMERGENCY => 7,
|
|
||||||
default => 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Log;
|
namespace Pinecore\Pinecore\Log;
|
||||||
|
|
||||||
use Pronchev\Pinecore\Config;
|
|
||||||
use Psr\Log\AbstractLogger;
|
use Psr\Log\AbstractLogger;
|
||||||
use Psr\Log\LogLevel;
|
use Psr\Log\LogLevel;
|
||||||
|
|
||||||
final class StdoutLogger extends AbstractLogger
|
final class JsonStreamLogger extends AbstractLogger
|
||||||
{
|
{
|
||||||
private readonly int $minLevel;
|
/** @param resource $stream */
|
||||||
private readonly string $channel;
|
public function __construct(
|
||||||
/** @var resource */
|
private readonly mixed $stream,
|
||||||
private readonly mixed $stdout;
|
private readonly int $minLevel,
|
||||||
|
private readonly string $channel,
|
||||||
public function __construct(Config $config)
|
) {}
|
||||||
{
|
|
||||||
$this->minLevel = self::levelValue($config->get('log.level', 'debug'));
|
|
||||||
$this->channel = $config->get('log.channel', 'app');
|
|
||||||
$this->stdout = fopen('php://stdout', 'w');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function log(mixed $level, string|\Stringable $message, array $context = []): void
|
public function log(mixed $level, string|\Stringable $message, array $context = []): void
|
||||||
{
|
{
|
||||||
@@ -37,7 +31,36 @@ final class StdoutLogger extends AbstractLogger
|
|||||||
$entry['context'] = self::serializeContext($context);
|
$entry['context'] = self::serializeContext($context);
|
||||||
}
|
}
|
||||||
|
|
||||||
fwrite($this->stdout, json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL);
|
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$line = json_encode($entry, $flags | JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
$line = json_encode([
|
||||||
|
'ts' => $entry['ts'],
|
||||||
|
'level' => $entry['level'],
|
||||||
|
'channel' => $entry['channel'],
|
||||||
|
'message' => $entry['message'],
|
||||||
|
'_logger_error' => 'context not encodable: ' . $e->getMessage(),
|
||||||
|
], $flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite($this->stream, $line . PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function levelValue(string $level): int
|
||||||
|
{
|
||||||
|
return match ($level) {
|
||||||
|
LogLevel::DEBUG => 0,
|
||||||
|
LogLevel::INFO => 1,
|
||||||
|
LogLevel::NOTICE => 2,
|
||||||
|
LogLevel::WARNING => 3,
|
||||||
|
LogLevel::ERROR => 4,
|
||||||
|
LogLevel::CRITICAL => 5,
|
||||||
|
LogLevel::ALERT => 6,
|
||||||
|
LogLevel::EMERGENCY => 7,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function interpolate(string $message, array $context): string
|
private static function interpolate(string $message, array $context): string
|
||||||
@@ -70,19 +93,4 @@ final class StdoutLogger extends AbstractLogger
|
|||||||
'trace' => $e->getTraceAsString(),
|
'trace' => $e->getTraceAsString(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function levelValue(string $level): int
|
|
||||||
{
|
|
||||||
return match ($level) {
|
|
||||||
LogLevel::DEBUG => 0,
|
|
||||||
LogLevel::INFO => 1,
|
|
||||||
LogLevel::NOTICE => 2,
|
|
||||||
LogLevel::WARNING => 3,
|
|
||||||
LogLevel::ERROR => 4,
|
|
||||||
LogLevel::CRITICAL => 5,
|
|
||||||
LogLevel::ALERT => 6,
|
|
||||||
LogLevel::EMERGENCY => 7,
|
|
||||||
default => 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Log;
|
namespace Pinecore\Pinecore\Log;
|
||||||
|
|
||||||
use Psr\Log\AbstractLogger;
|
use Psr\Log\AbstractLogger;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Model;
|
namespace Pinecore\Pinecore\Model;
|
||||||
|
|
||||||
interface Dto
|
interface Dto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Model;
|
namespace Pinecore\Pinecore\Model;
|
||||||
|
|
||||||
interface Entity
|
interface Entity
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Model;
|
namespace Pinecore\Pinecore\Model;
|
||||||
|
|
||||||
interface MongoEntity extends Entity
|
interface MongoEntity extends Entity
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Model;
|
namespace Pinecore\Pinecore\Model;
|
||||||
|
|
||||||
interface SqlEntity extends Entity
|
interface SqlEntity extends Entity
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm;
|
namespace Pinecore\Pinecore\Orm;
|
||||||
|
|
||||||
use MongoDB\Collection;
|
use MongoDB\Collection;
|
||||||
use MongoDB\Database;
|
use MongoDB\Database;
|
||||||
use Pronchev\Pinecore\Orm\Attributes\ForEntity;
|
use Pinecore\Pinecore\Orm\Attributes\ForEntity;
|
||||||
|
|
||||||
abstract class AbstractMongoRepository
|
abstract class AbstractMongoRepository
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,7 @@ abstract class AbstractMongoRepository
|
|||||||
$idMeta = $map->idField();
|
$idMeta = $map->idField();
|
||||||
$doc = $this->hydrator->dehydrate($entity);
|
$doc = $this->hydrator->dehydrate($entity);
|
||||||
|
|
||||||
if ($doc[$idMeta->field] === '') {
|
if (EntityMetadata::isNewId($doc[$idMeta->field] ?? null)) {
|
||||||
$doc[$idMeta->field] = $this->ids->generate();
|
$doc[$idMeta->field] = $this->ids->generate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
namespace Pinecore\Pinecore\Orm\Attributes;
|
||||||
|
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
namespace Pinecore\Pinecore\Orm\Attributes;
|
||||||
|
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
namespace Pinecore\Pinecore\Orm\Attributes;
|
||||||
|
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
namespace Pinecore\Pinecore\Orm\Attributes;
|
||||||
|
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
namespace Pinecore\Pinecore\Orm\Attributes;
|
||||||
|
|
||||||
#[\Attribute(\Attribute::TARGET_CLASS)]
|
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||||
final class ForEntity
|
final class ForEntity
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
namespace Pinecore\Pinecore\Orm\Attributes;
|
||||||
|
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
|
|||||||
17
src/Orm/Attributes/Index.php
Normal file
17
src/Orm/Attributes/Index.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pinecore\Pinecore\Orm\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
|
||||||
|
final class Index
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $direction = 1,
|
||||||
|
public readonly bool $unique = false,
|
||||||
|
public readonly bool $sparse = false,
|
||||||
|
public readonly ?string $name = null,
|
||||||
|
public readonly ?int $expireAfterSeconds = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
73
src/Orm/Console/EnsureIndexesCommand.php
Normal file
73
src/Orm/Console/EnsureIndexesCommand.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pinecore\Pinecore\Orm\Console;
|
||||||
|
|
||||||
|
use MongoDB\Database;
|
||||||
|
use Pinecore\Pinecore\Config;
|
||||||
|
use Pinecore\Pinecore\Console\ConsoleInput;
|
||||||
|
use Pinecore\Pinecore\Console\ConsoleOutput;
|
||||||
|
use Pinecore\Pinecore\Orm\EntityMap;
|
||||||
|
use Pinecore\Pinecore\Orm\IndexMetadata;
|
||||||
|
|
||||||
|
final class EnsureIndexesCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Config $config,
|
||||||
|
private readonly Database $db,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(ConsoleInput $input, ConsoleOutput $output): void
|
||||||
|
{
|
||||||
|
$entities = $this->config->getArray('orm.entities', []);
|
||||||
|
if ($entities === []) {
|
||||||
|
$output->error('No entities configured (set orm.entities in config)');
|
||||||
|
$output->setExitCode(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dryRun = (bool) ($input->options['dry-run'] ?? false);
|
||||||
|
|
||||||
|
foreach ($entities as $class) {
|
||||||
|
$map = EntityMap::of($class);
|
||||||
|
if ($map->collectionName === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($map->indexes as $idx) {
|
||||||
|
$keys = [$idx->field => $idx->direction];
|
||||||
|
$opts = self::buildOptions($idx);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$output->writeln(
|
||||||
|
"[dry-run] {$map->collectionName}.{$idx->field} "
|
||||||
|
. json_encode(['keys' => $keys, 'options' => $opts])
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->selectCollection($map->collectionName)->createIndex($keys, $opts);
|
||||||
|
$label = $idx->name ?? 'auto';
|
||||||
|
$output->writeln("ensured {$map->collectionName}.{$idx->field} ({$label})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private static function buildOptions(IndexMetadata $idx): array
|
||||||
|
{
|
||||||
|
$opts = [];
|
||||||
|
if ($idx->unique) {
|
||||||
|
$opts['unique'] = true;
|
||||||
|
}
|
||||||
|
if ($idx->sparse) {
|
||||||
|
$opts['sparse'] = true;
|
||||||
|
}
|
||||||
|
if ($idx->name !== null) {
|
||||||
|
$opts['name'] = $idx->name;
|
||||||
|
}
|
||||||
|
if ($idx->expireAfterSeconds !== null) {
|
||||||
|
$opts['expireAfterSeconds'] = $idx->expireAfterSeconds;
|
||||||
|
}
|
||||||
|
return $opts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm;
|
namespace Pinecore\Pinecore\Orm;
|
||||||
|
|
||||||
use Pronchev\Pinecore\Orm\Attributes\Collection;
|
use Pinecore\Pinecore\Model\MongoEntity;
|
||||||
use Pronchev\Pinecore\Orm\Attributes\Embedded;
|
use Pinecore\Pinecore\Orm\Attributes\Collection;
|
||||||
use Pronchev\Pinecore\Orm\Attributes\EmbeddedList;
|
use Pinecore\Pinecore\Orm\Attributes\Embedded;
|
||||||
use Pronchev\Pinecore\Orm\Attributes\Field;
|
use Pinecore\Pinecore\Orm\Attributes\EmbeddedList;
|
||||||
use Pronchev\Pinecore\Orm\Attributes\Id;
|
use Pinecore\Pinecore\Orm\Attributes\Field;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Id;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Index;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
|
use ReflectionIntersectionType;
|
||||||
|
use ReflectionNamedType;
|
||||||
|
use ReflectionParameter;
|
||||||
use ReflectionProperty;
|
use ReflectionProperty;
|
||||||
|
use ReflectionUnionType;
|
||||||
|
|
||||||
final class EntityMap
|
final class EntityMap
|
||||||
{
|
{
|
||||||
@@ -31,31 +37,73 @@ final class EntityMap
|
|||||||
$ref = new ReflectionClass($class);
|
$ref = new ReflectionClass($class);
|
||||||
|
|
||||||
$collectionAttrs = $ref->getAttributes(Collection::class);
|
$collectionAttrs = $ref->getAttributes(Collection::class);
|
||||||
if (empty($collectionAttrs)) {
|
$isMongoEntity = is_a($class, MongoEntity::class, true);
|
||||||
|
|
||||||
|
if ($isMongoEntity && empty($collectionAttrs)) {
|
||||||
throw new \LogicException("Class {$class} is missing #[Collection] attribute.");
|
throw new \LogicException("Class {$class} is missing #[Collection] attribute.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Collection $collection */
|
$collectionName = empty($collectionAttrs)
|
||||||
$collection = $collectionAttrs[0]->newInstance();
|
? null
|
||||||
|
: $collectionAttrs[0]->newInstance()->name;
|
||||||
|
|
||||||
|
$constructorParams = self::constructorParamsByName($ref);
|
||||||
$fields = [];
|
$fields = [];
|
||||||
|
$indexes = [];
|
||||||
|
|
||||||
foreach ($ref->getProperties() as $property) {
|
foreach ($ref->getProperties() as $property) {
|
||||||
$fieldMeta = self::buildFieldMetadata($property);
|
$fieldMeta = self::buildFieldMetadata($property, $constructorParams, $class);
|
||||||
if ($fieldMeta !== null) {
|
if ($fieldMeta !== null) {
|
||||||
$fields[] = $fieldMeta;
|
$fields[] = $fieldMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$indexAttrs = $property->getAttributes(Index::class);
|
||||||
|
if (empty($indexAttrs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($fieldMeta === null || $fieldMeta->isEmbedded || $fieldMeta->isEmbeddedList) {
|
||||||
|
throw new \LogicException(
|
||||||
|
"#[Index] on {$class}::\${$property->getName()} requires #[Field] or #[Id] on the same property."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
foreach ($indexAttrs as $indexAttr) {
|
||||||
|
/** @var Index $idx */
|
||||||
|
$idx = $indexAttr->newInstance();
|
||||||
|
$indexes[] = new IndexMetadata(
|
||||||
|
field: $fieldMeta->field,
|
||||||
|
direction: $idx->direction,
|
||||||
|
unique: $idx->unique,
|
||||||
|
sparse: $idx->sparse,
|
||||||
|
name: $idx->name,
|
||||||
|
expireAfterSeconds: $idx->expireAfterSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new EntityMetadata($collection->name, $fields);
|
return new EntityMetadata($collectionName, $fields, $indexes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function buildFieldMetadata(ReflectionProperty $property): ?FieldMetadata
|
/**
|
||||||
{
|
* @param array<string, ReflectionParameter> $constructorParams
|
||||||
|
*/
|
||||||
|
private static function buildFieldMetadata(
|
||||||
|
ReflectionProperty $property,
|
||||||
|
array $constructorParams,
|
||||||
|
string $entityClass,
|
||||||
|
): ?FieldMetadata {
|
||||||
$idAttrs = $property->getAttributes(Id::class);
|
$idAttrs = $property->getAttributes(Id::class);
|
||||||
$fieldAttrs = $property->getAttributes(Field::class);
|
$fieldAttrs = $property->getAttributes(Field::class);
|
||||||
$embeddedAttrs = $property->getAttributes(Embedded::class);
|
$embeddedAttrs = $property->getAttributes(Embedded::class);
|
||||||
$embeddedListAttrs = $property->getAttributes(EmbeddedList::class);
|
$embeddedListAttrs = $property->getAttributes(EmbeddedList::class);
|
||||||
|
|
||||||
|
if (empty($idAttrs) && empty($fieldAttrs) && empty($embeddedAttrs) && empty($embeddedListAttrs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$param = $constructorParams[$property->getName()] ?? null;
|
||||||
|
$isNullable = self::resolveNullable($property, $param);
|
||||||
|
$hasDefault = $param !== null && $param->isDefaultValueAvailable();
|
||||||
|
|
||||||
if (!empty($idAttrs)) {
|
if (!empty($idAttrs)) {
|
||||||
return new FieldMetadata(
|
return new FieldMetadata(
|
||||||
property: $property,
|
property: $property,
|
||||||
@@ -65,6 +113,8 @@ final class EntityMap
|
|||||||
isEmbedded: false,
|
isEmbedded: false,
|
||||||
isEmbeddedList: false,
|
isEmbeddedList: false,
|
||||||
embeddedClass: null,
|
embeddedClass: null,
|
||||||
|
isNullable: $isNullable,
|
||||||
|
hasConstructorDefault: $hasDefault,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,24 +129,29 @@ final class EntityMap
|
|||||||
isEmbedded: false,
|
isEmbedded: false,
|
||||||
isEmbeddedList: false,
|
isEmbeddedList: false,
|
||||||
embeddedClass: null,
|
embeddedClass: null,
|
||||||
|
isNullable: $isNullable,
|
||||||
|
hasConstructorDefault: $hasDefault,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($embeddedAttrs)) {
|
if (!empty($embeddedAttrs)) {
|
||||||
|
$embeddedClass = self::resolveEmbeddedClass($property, $entityClass);
|
||||||
return new FieldMetadata(
|
return new FieldMetadata(
|
||||||
property: $property,
|
property: $property,
|
||||||
field: $property->getName(),
|
field: $property->getName(),
|
||||||
isId: false,
|
isId: false,
|
||||||
type: self::typeName($property),
|
type: $embeddedClass,
|
||||||
isEmbedded: true,
|
isEmbedded: true,
|
||||||
isEmbeddedList: false,
|
isEmbeddedList: false,
|
||||||
embeddedClass: self::typeName($property),
|
embeddedClass: $embeddedClass,
|
||||||
|
isNullable: $isNullable,
|
||||||
|
hasConstructorDefault: $hasDefault,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($embeddedListAttrs)) {
|
|
||||||
/** @var EmbeddedList $embeddedList */
|
/** @var EmbeddedList $embeddedList */
|
||||||
$embeddedList = $embeddedListAttrs[0]->newInstance();
|
$embeddedList = $embeddedListAttrs[0]->newInstance();
|
||||||
|
self::validateEmbeddedListProperty($property, $embeddedList->class, $entityClass);
|
||||||
return new FieldMetadata(
|
return new FieldMetadata(
|
||||||
property: $property,
|
property: $property,
|
||||||
field: $property->getName(),
|
field: $property->getName(),
|
||||||
@@ -105,10 +160,99 @@ final class EntityMap
|
|||||||
isEmbedded: false,
|
isEmbedded: false,
|
||||||
isEmbeddedList: true,
|
isEmbeddedList: true,
|
||||||
embeddedClass: $embeddedList->class,
|
embeddedClass: $embeddedList->class,
|
||||||
|
isNullable: $isNullable,
|
||||||
|
hasConstructorDefault: $hasDefault,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
/**
|
||||||
|
* Validates that the property bearing #[Embedded] is typed with a single
|
||||||
|
* named, existing class (optionally nullable). Returns the FQCN.
|
||||||
|
*/
|
||||||
|
private static function resolveEmbeddedClass(ReflectionProperty $property, string $entityClass): string
|
||||||
|
{
|
||||||
|
$type = $property->getType();
|
||||||
|
$where = "{$entityClass}::\${$property->getName()}";
|
||||||
|
|
||||||
|
if ($type === null) {
|
||||||
|
throw new \LogicException("#[Embedded] on {$where} requires a typed property");
|
||||||
|
}
|
||||||
|
if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) {
|
||||||
|
throw new \LogicException(
|
||||||
|
"#[Embedded] on {$where} requires a single named class type, got compound type \"{$type}\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
|
||||||
|
throw new \LogicException(
|
||||||
|
"#[Embedded] on {$where} requires a single named class type, got \"{$type}\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = $type->getName();
|
||||||
|
if (!class_exists($class)) {
|
||||||
|
throw new \LogicException(
|
||||||
|
"#[Embedded] class \"{$class}\" on {$where} does not exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the property bearing #[EmbeddedList] is typed as array
|
||||||
|
* and that the configured item class actually exists.
|
||||||
|
*/
|
||||||
|
private static function validateEmbeddedListProperty(
|
||||||
|
ReflectionProperty $property,
|
||||||
|
string $itemClass,
|
||||||
|
string $entityClass,
|
||||||
|
): void {
|
||||||
|
$where = "{$entityClass}::\${$property->getName()}";
|
||||||
|
|
||||||
|
if (!class_exists($itemClass)) {
|
||||||
|
throw new \LogicException(
|
||||||
|
"#[EmbeddedList] class \"{$itemClass}\" on {$where} does not exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $property->getType();
|
||||||
|
if ($type === null) {
|
||||||
|
throw new \LogicException("#[EmbeddedList] on {$where} requires array property type");
|
||||||
|
}
|
||||||
|
if (!$type instanceof ReflectionNamedType || $type->getName() !== 'array') {
|
||||||
|
throw new \LogicException(
|
||||||
|
"#[EmbeddedList] on {$where} requires array property type, got \"{$type}\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, ReflectionParameter> */
|
||||||
|
private static function constructorParamsByName(ReflectionClass $ref): array
|
||||||
|
{
|
||||||
|
$ctor = $ref->getConstructor();
|
||||||
|
if ($ctor === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($ctor->getParameters() as $param) {
|
||||||
|
$map[$param->getName()] = $param;
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveNullable(ReflectionProperty $property, ?ReflectionParameter $param): bool
|
||||||
|
{
|
||||||
|
$type = $property->getType();
|
||||||
|
if ($type !== null && $type->allowsNull()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($param !== null) {
|
||||||
|
$paramType = $param->getType();
|
||||||
|
if ($paramType !== null && $paramType->allowsNull()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function typeName(ReflectionProperty $property): string
|
private static function typeName(ReflectionProperty $property): string
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm;
|
namespace Pinecore\Pinecore\Orm;
|
||||||
|
|
||||||
final class EntityMetadata
|
final class EntityMetadata
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param FieldMetadata[] $fields
|
* @param FieldMetadata[] $fields
|
||||||
|
* @param IndexMetadata[] $indexes
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $collectionName,
|
public readonly ?string $collectionName,
|
||||||
public readonly array $fields,
|
public readonly array $fields,
|
||||||
|
public readonly array $indexes = [],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function idField(): FieldMetadata
|
public function idField(): FieldMetadata
|
||||||
@@ -21,4 +23,9 @@ final class EntityMetadata
|
|||||||
}
|
}
|
||||||
throw new \LogicException('No #[Id] field found in entity metadata.');
|
throw new \LogicException('No #[Id] field found in entity metadata.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function isNewId(mixed $value): bool
|
||||||
|
{
|
||||||
|
return $value === null || $value === '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm;
|
namespace Pinecore\Pinecore\Orm;
|
||||||
|
|
||||||
use ReflectionProperty;
|
use ReflectionProperty;
|
||||||
|
|
||||||
@@ -14,5 +14,7 @@ final class FieldMetadata
|
|||||||
public readonly bool $isEmbedded,
|
public readonly bool $isEmbedded,
|
||||||
public readonly bool $isEmbeddedList,
|
public readonly bool $isEmbeddedList,
|
||||||
public readonly ?string $embeddedClass,
|
public readonly ?string $embeddedClass,
|
||||||
|
public readonly bool $isNullable,
|
||||||
|
public readonly bool $hasConstructorDefault,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm;
|
namespace Pinecore\Pinecore\Orm;
|
||||||
|
|
||||||
final class IdGenerator
|
final class IdGenerator
|
||||||
{
|
{
|
||||||
|
|||||||
15
src/Orm/IndexMetadata.php
Normal file
15
src/Orm/IndexMetadata.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pinecore\Pinecore\Orm;
|
||||||
|
|
||||||
|
final class IndexMetadata
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $field,
|
||||||
|
public readonly int $direction,
|
||||||
|
public readonly bool $unique,
|
||||||
|
public readonly bool $sparse,
|
||||||
|
public readonly ?string $name,
|
||||||
|
public readonly ?int $expireAfterSeconds,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Pronchev\Pinecore\Orm;
|
namespace Pinecore\Pinecore\Orm;
|
||||||
|
|
||||||
use MongoDB\Model\BSONDocument;
|
use MongoDB\Model\BSONDocument;
|
||||||
|
|
||||||
@@ -18,11 +18,21 @@ final class MongoHydrator
|
|||||||
foreach ($map->fields as $field) {
|
foreach ($map->fields as $field) {
|
||||||
$key = $field->field;
|
$key = $field->field;
|
||||||
$propName = $field->property->getName();
|
$propName = $field->property->getName();
|
||||||
$value = $doc[$key] ?? null;
|
|
||||||
|
|
||||||
if ($value instanceof \MongoDB\Model\BSONArray) {
|
if (!array_key_exists($key, $doc)) {
|
||||||
$value = $value->getArrayCopy();
|
if ($field->hasConstructorDefault) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($field->isNullable) {
|
||||||
|
$args[$propName] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new \LogicException(
|
||||||
|
"Missing required field '{$key}' for {$class} during hydration."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $this->normalizeBson($doc[$key]);
|
||||||
|
|
||||||
if ($field->isEmbedded && $value !== null) {
|
if ($field->isEmbedded && $value !== null) {
|
||||||
$value = $this->hydrate($field->embeddedClass, $this->toArray($value));
|
$value = $this->hydrate($field->embeddedClass, $this->toArray($value));
|
||||||
@@ -40,7 +50,10 @@ final class MongoHydrator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a typed entity to an array suitable for MongoDB storage.
|
* Convert a typed entity (or an embedded value object) to an array
|
||||||
|
* suitable for MongoDB storage. Embedded values recurse through the
|
||||||
|
* same path so that #[Field(name: ...)] is honoured symmetrically
|
||||||
|
* with hydrate().
|
||||||
*/
|
*/
|
||||||
public function dehydrate(object $entity): array
|
public function dehydrate(object $entity): array
|
||||||
{
|
{
|
||||||
@@ -51,9 +64,9 @@ final class MongoHydrator
|
|||||||
$value = $field->property->getValue($entity);
|
$value = $field->property->getValue($entity);
|
||||||
|
|
||||||
if ($field->isEmbedded && $value !== null) {
|
if ($field->isEmbedded && $value !== null) {
|
||||||
$value = $this->dehydrateEmbedded($value);
|
$value = $this->dehydrate($value);
|
||||||
} elseif ($field->isEmbeddedList && is_array($value)) {
|
} elseif ($field->isEmbeddedList && is_array($value)) {
|
||||||
$value = array_map(fn($item) => $this->dehydrateEmbedded($item), $value);
|
$value = array_map(fn($item) => $this->dehydrate($item), $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
$doc[$field->field] = $value;
|
$doc[$field->field] = $value;
|
||||||
@@ -62,14 +75,17 @@ final class MongoHydrator
|
|||||||
return $doc;
|
return $doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dehydrateEmbedded(object $embedded): array
|
private function normalizeBson(mixed $value): mixed
|
||||||
{
|
{
|
||||||
$result = [];
|
if ($value instanceof \MongoDB\Model\BSONArray) {
|
||||||
$ref = new \ReflectionClass($embedded);
|
return array_map($this->normalizeBson(...), $value->getArrayCopy());
|
||||||
foreach ($ref->getProperties() as $property) {
|
|
||||||
$result[$property->getName()] = $property->getValue($embedded);
|
|
||||||
}
|
}
|
||||||
return $result;
|
|
||||||
|
if ($value instanceof BSONDocument) {
|
||||||
|
return array_map($this->normalizeBson(...), iterator_to_array($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function toArray(array|BSONDocument $doc): array
|
private function toArray(array|BSONDocument $doc): array
|
||||||
|
|||||||
152
tests/Auth/JwtServiceTest.php
Normal file
152
tests/Auth/JwtServiceTest.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Auth;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Pinecore\Pinecore\Auth\AuthException;
|
||||||
|
use Pinecore\Pinecore\Auth\JwtService;
|
||||||
|
use Pinecore\Pinecore\Config;
|
||||||
|
|
||||||
|
final class JwtServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private const VALID_SECRET = '0123456789abcdef0123456789abcdef'; // 32 bytes
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function missingSecretIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('jwt.secret');
|
||||||
|
|
||||||
|
new JwtService(self::config(secret: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function emptySecretIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('jwt.secret');
|
||||||
|
|
||||||
|
new JwtService(self::config(secret: ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function shortSecretIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('32 bytes');
|
||||||
|
|
||||||
|
new JwtService(self::config(secret: str_repeat('a', 31)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function nonStringSecretIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('jwt.secret');
|
||||||
|
|
||||||
|
new JwtService(self::config(secret: 12345));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function exact32ByteSecretIsAccepted(): void
|
||||||
|
{
|
||||||
|
$service = new JwtService(self::config(secret: str_repeat('a', 32)));
|
||||||
|
|
||||||
|
$token = $service->issue('user-1');
|
||||||
|
self::assertSame('user-1', $service->verify($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function missingAccessTtlIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('jwt.access_ttl');
|
||||||
|
|
||||||
|
new JwtService(self::config(accessTtl: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function nonIntAccessTtlIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('jwt.access_ttl');
|
||||||
|
|
||||||
|
new JwtService(self::config(accessTtl: '3600'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function zeroAccessTtlIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('positive integer');
|
||||||
|
|
||||||
|
new JwtService(self::config(accessTtl: 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function negativeAccessTtlIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('positive integer');
|
||||||
|
|
||||||
|
new JwtService(self::config(accessTtl: -10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function issueAndVerifyRoundtripReturnsSubject(): void
|
||||||
|
{
|
||||||
|
$service = new JwtService(self::config());
|
||||||
|
|
||||||
|
$token = $service->issue('user-42');
|
||||||
|
$subject = $service->verify($token);
|
||||||
|
|
||||||
|
self::assertSame('user-42', $subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function tamperedTokenIsRejected(): void
|
||||||
|
{
|
||||||
|
$service = new JwtService(self::config());
|
||||||
|
$token = $service->issue('user-1');
|
||||||
|
|
||||||
|
// Flip last char of the signature segment.
|
||||||
|
$tampered = substr($token, 0, -1) . (substr($token, -1) === 'A' ? 'B' : 'A');
|
||||||
|
|
||||||
|
$this->expectException(AuthException::class);
|
||||||
|
$service->verify($tampered);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function tokenSignedWithDifferentSecretIsRejected(): void
|
||||||
|
{
|
||||||
|
$issuer = new JwtService(self::config(secret: str_repeat('x', 32)));
|
||||||
|
$verifier = new JwtService(self::config(secret: str_repeat('y', 32)));
|
||||||
|
|
||||||
|
$token = $issuer->issue('user-1');
|
||||||
|
|
||||||
|
$this->expectException(AuthException::class);
|
||||||
|
$verifier->verify($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function malformedTokenIsRejected(): void
|
||||||
|
{
|
||||||
|
$service = new JwtService(self::config());
|
||||||
|
|
||||||
|
$this->expectException(AuthException::class);
|
||||||
|
$service->verify('not-a-jwt');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function config(
|
||||||
|
mixed $secret = self::VALID_SECRET,
|
||||||
|
mixed $accessTtl = 3600,
|
||||||
|
): Config {
|
||||||
|
return new Config([
|
||||||
|
'jwt' => [
|
||||||
|
'secret' => $secret,
|
||||||
|
'access_ttl' => $accessTtl,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
tests/ConfigTest.php
Normal file
244
tests/ConfigTest.php
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Pinecore\Pinecore\Config;
|
||||||
|
|
||||||
|
final class ConfigTest extends TestCase
|
||||||
|
{
|
||||||
|
// -------- requireString --------
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireStringReturnsValueWhenPresent(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['jwt' => ['secret' => 'abcd']]);
|
||||||
|
|
||||||
|
self::assertSame('abcd', $config->requireString('jwt.secret'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireStringThrowsWhenMissing(): void
|
||||||
|
{
|
||||||
|
$config = new Config([]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('missing required key "jwt.secret"');
|
||||||
|
|
||||||
|
$config->requireString('jwt.secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireStringThrowsOnWrongType(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['jwt' => ['secret' => 42]]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('"jwt.secret" must be string, got int');
|
||||||
|
|
||||||
|
$config->requireString('jwt.secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- requireInt --------
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireIntReturnsValueWhenPresent(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['jwt' => ['ttl' => 900]]);
|
||||||
|
|
||||||
|
self::assertSame(900, $config->requireInt('jwt.ttl'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireIntRejectsNumericString(): void
|
||||||
|
{
|
||||||
|
// No silent coercion: "900" is not int.
|
||||||
|
$config = new Config(['jwt' => ['ttl' => '900']]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('"jwt.ttl" must be int, got string');
|
||||||
|
|
||||||
|
$config->requireInt('jwt.ttl');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- requireBool --------
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireBoolReturnsValueWhenPresent(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['flag' => true]);
|
||||||
|
|
||||||
|
self::assertTrue($config->requireBool('flag'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireBoolRejectsTruthyString(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['flag' => 'yes']);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('"flag" must be bool, got string');
|
||||||
|
|
||||||
|
$config->requireBool('flag');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- requireArray --------
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireArrayReturnsValueWhenPresent(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['cors' => ['origins' => ['*']]]);
|
||||||
|
|
||||||
|
self::assertSame(['origins' => ['*']], $config->requireArray('cors'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireArrayThrowsOnNull(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['cors' => null]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('"cors" must be array, got null');
|
||||||
|
|
||||||
|
$config->requireArray('cors');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- requireStringList --------
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireStringListReturnsListOfStrings(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['origins' => ['a', 'b', 'c']]);
|
||||||
|
|
||||||
|
self::assertSame(['a', 'b', 'c'], $config->requireStringList('origins'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireStringListRejectsAssociativeArray(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['origins' => ['x' => 'a', 'y' => 'b']]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('"origins" must be a list of strings, got associative array');
|
||||||
|
|
||||||
|
$config->requireStringList('origins');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function requireStringListRejectsNonStringElement(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['origins' => ['a', 42, 'c']]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('"origins" must be a list of strings, got int at index 1');
|
||||||
|
|
||||||
|
$config->requireStringList('origins');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- getString --------
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getStringReturnsDefaultWhenMissing(): void
|
||||||
|
{
|
||||||
|
$config = new Config([]);
|
||||||
|
|
||||||
|
self::assertSame('debug', $config->getString('log.level', 'debug'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getStringReturnsValueWhenPresent(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['log' => ['level' => 'warning']]);
|
||||||
|
|
||||||
|
self::assertSame('warning', $config->getString('log.level', 'debug'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getStringThrowsOnWrongTypeEvenWithDefault(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['log' => ['level' => ['debug']]]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('"log.level" must be string, got array');
|
||||||
|
|
||||||
|
$config->getString('log.level', 'debug');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- getInt --------
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getIntReturnsDefaultWhenMissing(): void
|
||||||
|
{
|
||||||
|
$config = new Config([]);
|
||||||
|
|
||||||
|
self::assertSame(8388608, $config->getInt('http.max_body_bytes', 8388608));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getIntDoesNotSilentlyCastString(): void
|
||||||
|
{
|
||||||
|
// Old (int) cast turned "8mb" into 8 — that's the regression we want.
|
||||||
|
$config = new Config(['http' => ['max_body_bytes' => '8mb']]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('"http.max_body_bytes" must be int, got string');
|
||||||
|
|
||||||
|
$config->getInt('http.max_body_bytes', 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- getBool --------
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getBoolReturnsDefaultWhenMissing(): void
|
||||||
|
{
|
||||||
|
$config = new Config([]);
|
||||||
|
|
||||||
|
self::assertFalse($config->getBool('app.cors.allow_credentials', false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getBoolRejectsTruthyString(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['app' => ['cors' => ['allow_credentials' => 'yes']]]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('"app.cors.allow_credentials" must be bool, got string');
|
||||||
|
|
||||||
|
$config->getBool('app.cors.allow_credentials', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- getArray --------
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getArrayReturnsDefaultWhenMissing(): void
|
||||||
|
{
|
||||||
|
$config = new Config([]);
|
||||||
|
|
||||||
|
self::assertSame([], $config->getArray('orm.entities', []));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getArrayRejectsScalar(): void
|
||||||
|
{
|
||||||
|
// Old (array) cast wrapped 'Foo' into ['Foo'] — silent and misleading.
|
||||||
|
$config = new Config(['orm' => ['entities' => 'App\\User']]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('"orm.entities" must be array, got string');
|
||||||
|
|
||||||
|
$config->getArray('orm.entities', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- legacy get() compatibility --------
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function legacyGetStillWorksForMixedAccess(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['anything' => ['nested' => 42]]);
|
||||||
|
|
||||||
|
self::assertSame(42, $config->get('anything.nested'));
|
||||||
|
self::assertNull($config->get('missing'));
|
||||||
|
self::assertSame('fallback', $config->get('missing', 'fallback'));
|
||||||
|
}
|
||||||
|
}
|
||||||
28
tests/Console/ConsoleApplicationTest.php
Normal file
28
tests/Console/ConsoleApplicationTest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Console;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Pinecore\Pinecore\Console\ConsoleApplication;
|
||||||
|
use Pinecore\Pinecore\Console\ConsoleRouter;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
|
final class ConsoleApplicationTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function helpOnEmptyRouterDoesNotCrash(): void
|
||||||
|
{
|
||||||
|
$app = new ConsoleApplication(
|
||||||
|
$this->createMock(ContainerInterface::class),
|
||||||
|
new ConsoleRouter([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$exit = $app->run(['bin/console']);
|
||||||
|
$output = (string) ob_get_clean();
|
||||||
|
|
||||||
|
self::assertSame(0, $exit);
|
||||||
|
self::assertStringContainsString('No commands registered', $output);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tests/Console/ConsoleRouterTest.php
Normal file
58
tests/Console/ConsoleRouterTest.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Console;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Pinecore\Pinecore\Console\ConsoleDefinition;
|
||||||
|
use Pinecore\Pinecore\Console\ConsoleRouter;
|
||||||
|
|
||||||
|
final class ConsoleRouterTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function matchesStaticSignature(): void
|
||||||
|
{
|
||||||
|
$router = new ConsoleRouter([
|
||||||
|
new ConsoleDefinition('mongo:ensure-indexes', 'Handler', 'Ensure indexes'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$match = $router->match('mongo:ensure-indexes');
|
||||||
|
|
||||||
|
self::assertTrue($match->found);
|
||||||
|
self::assertSame('Handler', $match->definition->handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function capturesNamedParameter(): void
|
||||||
|
{
|
||||||
|
$router = new ConsoleRouter([
|
||||||
|
new ConsoleDefinition('users:create {name}', 'Handler', 'Create user'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$match = $router->match('users:create admin');
|
||||||
|
|
||||||
|
self::assertTrue($match->found);
|
||||||
|
self::assertSame(['name' => 'admin'], $match->pathParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function literalDotInSignatureIsNotRegexWildcard(): void
|
||||||
|
{
|
||||||
|
$router = new ConsoleRouter([
|
||||||
|
new ConsoleDefinition('schema.v1:apply', 'Handler', 'Apply schema'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertFalse($router->match('schemaXv1:apply')->found);
|
||||||
|
self::assertTrue($router->match('schema.v1:apply')->found);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function unknownCommandReturnsNotFound(): void
|
||||||
|
{
|
||||||
|
$router = new ConsoleRouter([
|
||||||
|
new ConsoleDefinition('users:create {email}', 'Handler', 'Create user'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertFalse($router->match('users:delete admin')->found);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tests/Fixtures/Orm/EmbeddedAddress.php
Normal file
13
tests/Fixtures/Orm/EmbeddedAddress.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Fixtures\Orm;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Field;
|
||||||
|
|
||||||
|
final class EmbeddedAddress
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Field(name: 'street_name')] public readonly string $streetName,
|
||||||
|
#[Field] public readonly string $city,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
17
tests/Fixtures/Orm/EmbeddedListOnPrimitive.php
Normal file
17
tests/Fixtures/Orm/EmbeddedListOnPrimitive.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Fixtures\Orm;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Model\MongoEntity;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Collection;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\EmbeddedList;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Id;
|
||||||
|
|
||||||
|
#[Collection(name: 'invalid_embedded_list_primitive')]
|
||||||
|
final class EmbeddedListOnPrimitive implements MongoEntity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Id] public readonly string $id,
|
||||||
|
#[EmbeddedList(class: EmbeddedAddress::class)] public readonly string $items = '',
|
||||||
|
) {}
|
||||||
|
}
|
||||||
17
tests/Fixtures/Orm/EmbeddedListWithMissingClass.php
Normal file
17
tests/Fixtures/Orm/EmbeddedListWithMissingClass.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Fixtures\Orm;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Model\MongoEntity;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Collection;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\EmbeddedList;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Id;
|
||||||
|
|
||||||
|
#[Collection(name: 'invalid_embedded_list_missing')]
|
||||||
|
final class EmbeddedListWithMissingClass implements MongoEntity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Id] public readonly string $id,
|
||||||
|
#[EmbeddedList(class: 'App\\NonExistentEmbeddedItem')] public readonly array $items = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
17
tests/Fixtures/Orm/EmbeddedOnPrimitive.php
Normal file
17
tests/Fixtures/Orm/EmbeddedOnPrimitive.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Fixtures\Orm;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Model\MongoEntity;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Collection;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Embedded;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Id;
|
||||||
|
|
||||||
|
#[Collection(name: 'invalid_embedded_primitive')]
|
||||||
|
final class EmbeddedOnPrimitive implements MongoEntity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Id] public readonly string $id,
|
||||||
|
#[Embedded] public readonly string $payload,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
17
tests/Fixtures/Orm/EmbeddedOnUnion.php
Normal file
17
tests/Fixtures/Orm/EmbeddedOnUnion.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Fixtures\Orm;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Model\MongoEntity;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Collection;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Embedded;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Id;
|
||||||
|
|
||||||
|
#[Collection(name: 'invalid_embedded_union')]
|
||||||
|
final class EmbeddedOnUnion implements MongoEntity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Id] public readonly string $id,
|
||||||
|
#[Embedded] public readonly EmbeddedAddress|SimpleUser $payload,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
19
tests/Fixtures/Orm/EmbeddedOnUntypedProperty.php
Normal file
19
tests/Fixtures/Orm/EmbeddedOnUntypedProperty.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Fixtures\Orm;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Model\MongoEntity;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Collection;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Embedded;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Id;
|
||||||
|
|
||||||
|
#[Collection(name: 'invalid_embedded_untyped')]
|
||||||
|
final class EmbeddedOnUntypedProperty implements MongoEntity
|
||||||
|
{
|
||||||
|
#[Embedded]
|
||||||
|
public $payload;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[Id] public readonly string $id,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
15
tests/Fixtures/Orm/EntityMissingCollection.php
Normal file
15
tests/Fixtures/Orm/EntityMissingCollection.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Fixtures\Orm;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Model\MongoEntity;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Field;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Id;
|
||||||
|
|
||||||
|
final class EntityMissingCollection implements MongoEntity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Id] public readonly string $id,
|
||||||
|
#[Field] public readonly string $email,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
15
tests/Fixtures/Orm/EntityWithoutId.php
Normal file
15
tests/Fixtures/Orm/EntityWithoutId.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Fixtures\Orm;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Model\MongoEntity;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Collection;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Field;
|
||||||
|
|
||||||
|
#[Collection(name: 'no_id_entities')]
|
||||||
|
final class EntityWithoutId implements MongoEntity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Field] public readonly string $email,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
34
tests/Fixtures/Orm/IndexedEntity.php
Normal file
34
tests/Fixtures/Orm/IndexedEntity.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Fixtures\Orm;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Model\MongoEntity;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Collection;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Field;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Id;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Index;
|
||||||
|
|
||||||
|
#[Collection(name: 'indexed_entities')]
|
||||||
|
final class IndexedEntity implements MongoEntity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Id]
|
||||||
|
public readonly string $id,
|
||||||
|
|
||||||
|
#[Field]
|
||||||
|
#[Index(unique: true, name: 'uniq_email')]
|
||||||
|
public readonly string $email,
|
||||||
|
|
||||||
|
#[Field(name: 'created_at')]
|
||||||
|
#[Index(direction: -1, expireAfterSeconds: 3600)]
|
||||||
|
public readonly int $createdAt,
|
||||||
|
|
||||||
|
#[Field]
|
||||||
|
#[Index(sparse: true)]
|
||||||
|
public readonly ?string $referralCode = null,
|
||||||
|
|
||||||
|
// No #[Index] on this property — should not appear in indexes
|
||||||
|
#[Field]
|
||||||
|
public readonly ?string $name = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
22
tests/Fixtures/Orm/IndexedEntityNoField.php
Normal file
22
tests/Fixtures/Orm/IndexedEntityNoField.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Fixtures\Orm;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Model\MongoEntity;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Collection;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Field;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Id;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Index;
|
||||||
|
|
||||||
|
#[Collection(name: 'indexed_no_field')]
|
||||||
|
final class IndexedEntityNoField implements MongoEntity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Id]
|
||||||
|
public readonly string $id,
|
||||||
|
|
||||||
|
// #[Index] without #[Field] — must throw LogicException at metadata time
|
||||||
|
#[Index]
|
||||||
|
public readonly string $orphan,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
18
tests/Fixtures/Orm/MixedPropsEntity.php
Normal file
18
tests/Fixtures/Orm/MixedPropsEntity.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Pinecore\Pinecore\Fixtures\Orm;
|
||||||
|
|
||||||
|
use Pinecore\Pinecore\Model\MongoEntity;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Collection;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Field;
|
||||||
|
use Pinecore\Pinecore\Orm\Attributes\Id;
|
||||||
|
|
||||||
|
#[Collection(name: 'mixed_props')]
|
||||||
|
final class MixedPropsEntity implements MongoEntity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Id] public readonly string $id,
|
||||||
|
#[Field] public readonly string $email,
|
||||||
|
public readonly string $untagged = 'ignored',
|
||||||
|
) {}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user