Compare commits

..

29 Commits

Author SHA1 Message Date
3d5cf49cfd Make ConsoleRouter resolvable on a bare skeleton
Provide a default empty ConsoleRouter binding plus an optional
config/commands.php convention (mirrors config/routes.php), and guard
ConsoleApplication::printHelp against an empty command list — so
bin/console help works on a fresh project without any manual DI wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 05:33:24 +03:00
9470fa521b Rename package from pronchev/pinecore to pinecore/pinecore
Updates the composer name, PSR-4 namespace (Pronchev\Pinecore →
Pinecore\Pinecore), test namespace, and all docs to reflect the new
repository owner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:20:23 +03:00
32d7fe4b91 Drop stale dev docs and the pre-merge update rule
docs/development/patterns.md and testing.md were authored against an
earlier shape of the framework — every code example was wrong: the
non-existent Kernel::init / Kernel::getContainer, a routes/ directory
that's actually config/routes.php, RouteDefinition with a method-name
arg it never had, a MiddlewareInterface::handle(callable $next)
signature that doesn't exist, Config::get as a static method, and a
CorsMiddleware that was never written. The architecture/* docs already
cover the same ground accurately, so deleting these two files is
strictly less misleading than maintaining them.

conventions.md trimmed to namespace + code style — generic git
conventions and ADR rules removed (the latter live in
decisions/README.md).

docs/README.md: rewritten index to match the surviving file set, and
dropped the "do not touch architecture/development until merged"
update rule, which no longer reflects how this project is maintained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:58:28 +03:00
b810449536 Add typed Config accessors and remove silent type coercion
Config previously exposed only get($key, $default): mixed, so each
caller reinvented type-checking — usually as a (int)/(string)/(bool)
cast that silently mangled malformed config. Examples that used to
slip through and now fail loudly:

- http.max_body_bytes = "8mb" → (int) returned 8 (8-byte limit)
- worker.max_requests = "forever" → (int) returned 0 (unlimited)
- app.cors.allow_credentials = "yes" → (bool) returned true
- orm.entities = "App\\User" → (array) wrapped to ["App\\User"]
- app.cors.allowed_origins element typo → strval coerced silently

New API:
- requireString/Int/Bool/Array/StringList — required keys, fail-fast
  on missing or wrong type
- getString/Int/Bool/Array — defaulted, but still fail on wrong type
  (no silent casting)

All six existing call sites (JwtService, CorsPolicy, HttpApplication,
ContainerFactory logger + WorkerRunner factory, EnsureIndexesCommand)
moved to the typed API. CorsPolicy's private asStringList helper
deleted in favor of Config::requireStringList. Legacy get() stays for
genuine mixed access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:51:46 +03:00
3f7e80e03f Validate Embedded and EmbeddedList attributes in EntityMap
EntityMap previously accepted #[Embedded] and #[EmbeddedList] blindly:
the embedded class for #[Embedded] was inferred from a raw stringified
property type (so untyped, primitive, nullable, union, or intersection
all flowed through to MongoHydrator and crashed with "Class X not
found" at first document read). #[EmbeddedList(class: ...)] never
checked that the class existed or that the property was actually array.

Validation now runs at metadata-build time (cached per class):

- #[Embedded] requires a single named, existing class type. Nullable
  (?Foo) is allowed and resolved to Foo. Primitive, mixed, untyped,
  union, and intersection types are rejected.
- #[EmbeddedList] requires the configured class to exist and the
  property type to be array (or ?array).

Errors throw \LogicException with {Class}::${property} for fast
localization, matching the existing #[Index] validation style.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:44:41 +03:00
0f68d610f5 Validate Origin against allowlist with proper CORS semantics
Replaces the dumb config-string pass-through with a CorsPolicy value
object. Origin is matched against an explicit allowlist; on miss, no
Allow-Origin header is emitted (default-deny). Wildcard origin is
still supported but rejected at construction when combined with
allow_credentials=true (incompatible per Fetch Standard).

Vary: Origin is added to all responses except pure ['*'] without
credentials, preventing cache poisoning across origins.
Allow-Credentials is emitted only when configured and an origin was
actually allowed. Allow-Methods / Allow-Headers are now preflight-only,
as the spec dictates.

OPTIONS preflight runs the same Origin resolution before any routing
or middleware, so disallowed origins receive 204 with no CORS headers
(plus Vary).

Config schema changes: allowed_origins / allowed_methods /
allowed_headers are arrays now, plus allow_credentials. Pre-1.0
breaking change; old single-string keys are no longer recognized.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:36:55 +03:00
f8b28c6d55 Remove Kernel::container() service-locator surface
Kernel held a static container field exposed via Kernel::container().
That static getter was the textbook service-locator anti-pattern: any
class could grab dependencies bypassing constructor injection, and the
static state complicated testing. Framework code itself never used it
— only entry-script doc examples did.

Kernel::boot() now simply returns the constructed container. Entry
scripts (worker.php, bin/console) capture it and resolve their first
service from there. Beyond the composition root, dependencies flow
through DI as before.

Breaking change: Kernel::container() is gone. Pre-1.0 framework, clean
cut over a deprecation shim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:27:17 +03:00
c71b61472d Source request data from adapter in worker-loop mode
WorkerRunner no longer reads superglobals in worker-loop mode.
Worker runtimes (RoadRunner, FrankenPHP, ReactPHP, Swoole, Amphp) do
not repopulate the superglobals or php://input between iterations, so
reading them silently produced stale or empty data on the 2nd request
onward.

The $handler callable passed to the adapter now requires
(array $get, array $post, array $cookie, array $files, array $server,
string $rawBody) — the adapter sources these from its runtime.
HttpApplication::handleRequest accepts an optional $rawBody; when null,
it falls back to reading php://input (SAPI/CGI/FPM path).

Single-request mode (run() with no adapter) keeps reading superglobals
directly — that path is contractually SAPI where PHP populates them.

Breaking change for adapter callers: $handler now requires arguments.
Pinecore is pre-1.0 and the previous signature was a footgun.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:22:20 +03:00
c649f9b1cb Make logger resilient to non-encodable context
Logger no longer propagates JsonException when context contains
resources, cyclic references, NAN/INF, or invalid UTF-8. Invalid UTF-8
is silently substituted via JSON_INVALID_UTF8_SUBSTITUTE; harder cases
fall back to an entry without context plus a _logger_error field
explaining the failure. PSR-3 requires logging not to break the
application flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:14:35 +03:00
7740fa2ecd Replace StdoutLogger/FileLogger with JsonStreamLogger
The two classes were 84 of 88 lines identical — same JSON formatting,
PSR-3 interpolation, throwable serialization, and level table. Only the
stream target differed. Collapse them into JsonStreamLogger, which takes
any opened resource, and move stream-opening into a ContainerFactory
helper that fail-fasts on fopen() returning false (closes the silent-
warning gap in the old FileLogger boot path).

BREAKING CHANGE: StdoutLogger and FileLogger are removed. Code injecting
LoggerInterface is unaffected; code typing on the concrete classes must
switch to LoggerInterface or build JsonStreamLogger directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:06:50 +03:00
ecabd23142 Cap request body size and expose Content-Length header
extractHeaders() lifted CONTENT_TYPE out of \$_SERVER but forgot
CONTENT_LENGTH, which has the same CGI naming convention. Without it
HttpApplication had no way to enforce a body-size limit before reading.
Add the symmetric block, then in HttpApplication: read http.max_body_bytes
once at construction (default 8 MiB, 0 disables), bail out with 413 if
Content-Length declares too much, and re-check actual length after
reading to guard against a lying or absent Content-Length header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:57:37 +03:00
cfa33ad371 Drive WorkerRunner request limit from config, not \$_SERVER
MAX_REQUESTS was read from \$_SERVER, but worker runtimes (RoadRunner,
FrankenPHP, FPM) don't put process env vars there by default, so the
limit was silently ignored in real deployments. Move the source of
truth to the worker.max_requests config key, wired via a DI factory in
ContainerFactory; the constructor takes an int with 0 = unlimited.
Apps that want env-driven config can map MAX_REQUESTS through helpers.env()
in their own config file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:59:34 +03:00
4dd374ca8d Validate JWT secret length and access TTL at construction
The previous '=== empty string' check let null/non-string and short
secrets through, surfacing as obscure TypeErrors deeper in lcobucci/jwt.
HS256 (RFC 7518 §3.2) requires a key of at least 32 bytes; enforce that
and reject non-positive TTLs with a clear message naming the config key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:54:31 +03:00
4eccef1783 Escape regex metachars in router compilation
Literal segments of route paths and console signatures were spliced into
the compiled regex without preg_quote, so '.', '+', etc. acted as regex
operators and could match unintended inputs (e.g. /api/v1.0/users/{id}
matched /api/v1X0/users/42).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:50:10 +03:00
88e9bcaeb1 Add minimal README with link to docs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:54:09 +03:00
5f1d8bd46e Rename .claude knowledge base directory to docs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:32:50 +03:00
626a478d6c Remove .claude/tasks workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:29:30 +03:00
25a0b268ed Add graceful shutdown to worker loop on SIGTERM/SIGINT
Until now the worker loop only exited when MAX_REQUESTS was hit or
the adapter returned false. SIGTERM from k8s/systemd/docker stop
either killed the process mid-request (response dropped, resources
not closed) or was ignored entirely — PHP doesn't dispatch signals
without explicit setup. For long-running workers in prod this
is unacceptable.

WorkerRunner now installs SIGTERM and SIGINT handlers via
pcntl_async_signals(true) when entering loop mode. Both flip a
shouldShutdown flag rather than aborting; PHP can't safely interrupt
mid-execution. After the current $loop iteration finishes and
$app->terminate() runs, the flag is checked and the loop exits — even
if the adapter would keep going. Forced timeout is the orchestrator's
job (SIGKILL after grace period); we don't try to preempt PHP.

Single-request mode (run() with no adapter) doesn't install handlers —
SAPI/CGI manages signals there. Without the pcntl extension, handler
setup silently no-ops; on our php:8.2-cli-alpine base it's available.

Public requestShutdown() lets applications drain themselves
programmatically — e.g. exit after a health check fails.

HttpApplication is no longer final so PHPUnit 11 can createMock it for
WorkerRunner tests; PHPUnit 11 can't double final classes without
bypass-finals, and adding that as a dev-dep just for one test class
isn't worth it. HttpApplication is internal — no public-API contract
broken.

Tests: tests/Http/WorkerRunnerTest.php (4 cases) — single-request
calls handle()+terminate() once; adapter returning false exits
cleanly; MAX_REQUESTS=3 caps iterations; requestShutdown() mid-loop
exits even when adapter wants to continue. Real signal delivery
(SIGTERM → handler) isn't tested directly — that's OS-level and
flaky in CI; verifying the public requestShutdown() entry point that
the signal handler triggers covers the loop logic.

Full suite: 48 tests, 84 assertions, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:26:05 +03:00
a4381f7f9c Add #[Index] attribute and mongo:ensure-indexes command
Declarative MongoDB index management. Annotate entity properties with
#[Index] and a new framework-supplied console command syncs them to
the database — no more hand-rolled createIndex calls in mongo shell
that drift between environments.

#[Index] is repeatable, TARGET_PROPERTY, with direction, unique,
sparse, name, and expireAfterSeconds (TTL). The attribute must sit
alongside #[Field] or #[Id] on the same property; standalone or on
#[Embedded]/#[EmbeddedList] it throws LogicException at metadata-build
time. Field rename via #[Field(name: 'x')] is honoured — the index is
created on the BSON name, not the PHP property name.

EntityMetadata gains a public readonly array $indexes (list of
IndexMetadata DTOs). EntityMap::build collects them in the same pass
that builds FieldMetadata.

EnsureIndexesCommand (src/Orm/Console/) reads orm.entities from Config,
iterates EntityMap::of(...)->indexes, and calls
Database::selectCollection(...)->createIndex(keys, options) for each.
Embedded classes (no #[Collection]) are skipped. --dry-run prints the
plan without touching the DB. Empty orm.entities exits with code 1
and a stderr message. The command is additive only — it never drops
existing indexes (chargé separate concern, dangerous in prod).

Discovery is config-based, not filesystem scanning. Apps register
the entity list explicitly in config/orm.php and the ConsoleDefinition
in their console config; the framework ships only the handler class
and documents how to wire it (architecture/console.md).

Composite (multi-field) indexes, --prune, and text/geo specialised
indexes are deferred to future bundles.

Tests: 6 cases for EntityMap index parsing (defaults, unique+name,
field-rename + TTL, sparse, count, LogicException on orphan #[Index]),
plus 4 cases for the command using SpyDatabase/SpyCollection that
extend MongoDB\Database/Collection with empty constructors and
record createIndex calls. Full suite: 44 tests, 78 assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:15:36 +03:00
b13885b002 Parse request body by Content-Type with 400 on bad input
Request::fromGlobals now accepts $_POST and chooses how to fill body()
based on Content-Type: JSON via json_decode, urlencoded via $_POST
(POST) or parse_str (PUT/PATCH/...), multipart via $_POST. Anything
else, or empty Content-Type, yields []. Charset suffix on JSON is
honoured.

Body parse failures throw HttpException(400) directly from fromGlobals
("Invalid JSON body" / "JSON body must be an object or array") so the
controller never sees garbage. Top-level JSON list ([1,2,3]) is
accepted as a valid body.

HttpApplication moves Request::fromGlobals into dispatch's try block
so the new HttpException maps to a 400 response uniformly. OPTIONS
preflight reads the method directly from $server and skips body
parsing entirely. The opportunistic catch (\JsonException) in dispatch
is gone — controller-level json_decode errors now correctly produce
500 instead of being mislabelled as "Invalid JSON body".

Adds Request::input(key, default) for per-field access alongside the
existing body() that returns the full array.

Tests: tests/Http/RequestTest.php (14 cases) covering JSON
object/list/scalar/null/malformed/charset/empty, urlencoded POST/PUT,
multipart, missing Content-Type, GET, and input() with/without default.
Suite: 34 tests, 55 assertions, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:57:39 +03:00
6dd41c7b47 Add PHPUnit tests and fix ORM hydration bugs
Set up PHPUnit 11 inside docker-compose (PHP 8.2 CLI image with ext-mongodb)
and use red tests to drive three fixes in the ORM layer:

- MongoHydrator::dehydrate now recurses through EntityMap so #[Field(name)]
  on embedded value objects is honoured symmetrically with hydrate.
  dehydrateEmbedded was reading raw property names via ReflectionClass,
  which silently broke round-trip when an embedded property had a renamed
  field. See ADR-001.
- MongoHydrator::hydrate now resolves missing keys as: constructor default,
  then nullable null, then LogicException with the field/class. Previously
  every missing key became null, leaking as a TypeError from the entity
  constructor for non-nullable typed properties.
- AbstractMongoRepository::persist detects new entities via the new
  EntityMetadata::isNewId helper, which treats both '' and null as "new"
  (was strict === '', so #[Id] ?string $id = null left null in the upsert
  filter and could collide with other null-id documents).

EntityMap also makes #[Collection] optional for non-MongoEntity classes so
embedded value objects can declare #[Field]/#[Embedded] without owning a
Mongo collection.

Tests: 20 cases covering simple/embedded/embedded-list round-trip, BSON
inputs, missing-field semantics, EntityMap building rules, isNewId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:43:35 +03:00
ac5b5f9246 Remove FrankenPHP targeting 2026-04-23 17:39:01 +03:00
1d97114915 Fix ConsoleApplication parsing 2026-04-21 14:21:33 +03:00
ece0147b3a Fix MongoHydrator BSON handling 2026-04-12 01:57:12 +03:00
d133898383 Enable PHP DI attributes 2026-04-12 01:50:07 +03:00
9f8c2b1959 Fix missing nbf 2026-04-12 01:40:54 +03:00
404e2089eb Remove deprecated method 2026-04-12 01:24:19 +03:00
58c9b298db Register Logger 2026-04-06 20:38:57 +03:00
72415949e3 Register Config and Environment 2026-04-06 20:30:34 +03:00
119 changed files with 6115 additions and 1137 deletions

View File

@@ -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`

View File

@@ -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),
]);
};
```

View File

@@ -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',
],
];
```

View File

@@ -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,
]);
```

View File

@@ -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]`

View File

@@ -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 |

View File

@@ -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
```

View File

@@ -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 — только новые файлы, никогда не правь существующие.

View File

@@ -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-ответ даже при ошибке.

View File

@@ -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();
```

View File

@@ -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` для [решения]

View File

@@ -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
View 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
View File

@@ -0,0 +1,4 @@
.git/
.idea/
vendor/
var/

View File

@@ -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
View 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

View File

@@ -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

File diff suppressed because it is too large Load Diff

12
docker-compose.yml Normal file
View 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
View 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 и шаблон для новых записей.

View 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),
]);
};
```

View File

@@ -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
View 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,
],
// ...
];
```

View 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
View 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`.

View File

@@ -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
View 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 байт) |

View 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`.

View File

@@ -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

View 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.

View 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
View 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>

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Auth; namespace Pinecore\Pinecore\Auth;
final class AuthContext final class AuthContext
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Auth; namespace Pinecore\Pinecore\Auth;
use RuntimeException; use RuntimeException;

View File

@@ -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
{ {

View File

@@ -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());

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Auth; namespace Pinecore\Pinecore\Auth;
interface UserProviderInterface interface UserProviderInterface
{ {

View File

@@ -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)) {

View File

@@ -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));

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Console; namespace Pinecore\Pinecore\Console;
final class ConsoleDefinition final class ConsoleDefinition
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Console; namespace Pinecore\Pinecore\Console;
final class ConsoleInput final class ConsoleInput
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Console; namespace Pinecore\Pinecore\Console;
final class ConsoleMatch final class ConsoleMatch
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Console; namespace Pinecore\Pinecore\Console;
final class ConsoleOutput final class ConsoleOutput
{ {

View File

@@ -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 [

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Console; namespace Pinecore\Pinecore\Console;
final class OptionDefinition final class OptionDefinition
{ {

View File

@@ -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;
}
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore; namespace Pinecore\Pinecore;
class Environment class Environment
{ {

View File

@@ -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
View 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;
}
}

View File

@@ -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;
}
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Http; namespace Pinecore\Pinecore\Http;
final class HttpException extends \RuntimeException final class HttpException extends \RuntimeException
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Http; namespace Pinecore\Pinecore\Http;
interface MiddlewareInterface interface MiddlewareInterface
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Http; namespace Pinecore\Pinecore\Http;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;

View File

@@ -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;
} }
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Http; namespace Pinecore\Pinecore\Http;
final class Response final class Response
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Http; namespace Pinecore\Pinecore\Http;
final class RouteDefinition final class RouteDefinition
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Http; namespace Pinecore\Pinecore\Http;
final class RouteMatch final class RouteMatch
{ {

View File

@@ -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,
); );

View File

@@ -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());
}
} }

View File

@@ -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;
} }
} }

View File

@@ -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;

View File

@@ -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,
};
}
}

View File

@@ -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,
};
}
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Log; namespace Pinecore\Pinecore\Log;
use Psr\Log\AbstractLogger; use Psr\Log\AbstractLogger;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Model; namespace Pinecore\Pinecore\Model;
interface Dto interface Dto
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Model; namespace Pinecore\Pinecore\Model;
interface Entity interface Entity
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Model; namespace Pinecore\Pinecore\Model;
interface MongoEntity extends Entity interface MongoEntity extends Entity
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Model; namespace Pinecore\Pinecore\Model;
interface SqlEntity extends Entity interface SqlEntity extends Entity
{ {

View File

@@ -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();
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Orm\Attributes; namespace Pinecore\Pinecore\Orm\Attributes;
use Attribute; use Attribute;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Orm\Attributes; namespace Pinecore\Pinecore\Orm\Attributes;
use Attribute; use Attribute;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Orm\Attributes; namespace Pinecore\Pinecore\Orm\Attributes;
use Attribute; use Attribute;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Orm\Attributes; namespace Pinecore\Pinecore\Orm\Attributes;
use Attribute; use Attribute;

View File

@@ -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

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace Pronchev\Pinecore\Orm\Attributes; namespace Pinecore\Pinecore\Orm\Attributes;
use Attribute; use Attribute;

View 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,
) {}
}

View 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;
}
}

View File

@@ -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

View File

@@ -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 === '';
}
} }

View File

@@ -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,
) {} ) {}
} }

View File

@@ -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
View 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,
) {}
}

View File

@@ -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

View 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
View 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'));
}
}

View 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);
}
}

View 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);
}
}

View 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,
) {}
}

View 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 = '',
) {}
}

View 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 = [],
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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