Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
114
.editorconfig
Normal file
114
.editorconfig
Normal file
@@ -0,0 +1,114 @@
|
||||
# EditorConfig: https://editorconfig.org
|
||||
root = true
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Все файлы (базовые настройки)
|
||||
# ──────────────────────────────────────────
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 120
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# PHP (PSR-12)
|
||||
# ──────────────────────────────────────────
|
||||
[*.php]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 120
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Шаблоны (Blade, Twig, Latte и др.)
|
||||
# ──────────────────────────────────────────
|
||||
[*.{blade.php,twig,html,htm,latte}]
|
||||
indent_size = 2
|
||||
max_line_length = 200
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# JavaScript / TypeScript / Vue
|
||||
# ──────────────────────────────────────────
|
||||
[*.{js,jsx,ts,tsx,vue,mjs,cjs}]
|
||||
indent_size = 2
|
||||
max_line_length = 100
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# CSS / SCSS / Less
|
||||
# ──────────────────────────────────────────
|
||||
[*.{css,scss,less,sass}]
|
||||
indent_size = 2
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# JSON / JSON5
|
||||
# ──────────────────────────────────────────
|
||||
[*.{json,json5}]
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# YAML
|
||||
# ──────────────────────────────────────────
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# XML / SVG
|
||||
# ──────────────────────────────────────────
|
||||
[*.{xml,svg}]
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Markdown
|
||||
# ──────────────────────────────────────────
|
||||
[*.{md,mdx}]
|
||||
trim_trailing_whitespace = false # пробелы в конце = <br> в MD
|
||||
max_line_length = off
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Shell-скрипты
|
||||
# ──────────────────────────────────────────
|
||||
[*.{sh,bash,zsh}]
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Makefile (требует табуляцию)
|
||||
# ──────────────────────────────────────────
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
tab_width = 4
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Docker
|
||||
# ──────────────────────────────────────────
|
||||
[{Dockerfile,*.dockerfile}]
|
||||
indent_size = 2
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# .env файлы
|
||||
# ──────────────────────────────────────────
|
||||
[.env*]
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Composer / NPM
|
||||
# ──────────────────────────────────────────
|
||||
[{composer,package}{.json,.lock}]
|
||||
indent_size = 4
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Git
|
||||
# ──────────────────────────────────────────
|
||||
[{.gitattributes,.gitignore,.gitmodules}]
|
||||
indent_size = 2
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.idea
|
||||
/vendor/
|
||||
var/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
113
CLAUDE.md
Normal file
113
CLAUDE.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Package
|
||||
|
||||
`pronchev/pinecore` — minimal PHP framework for FrankenPHP long-running workers.
|
||||
|
||||
**Namespace:** `Pronchev\Pinecore\` → `src/` (PSR-4)
|
||||
|
||||
No lint or test commands configured yet.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Request lifecycle:**
|
||||
|
||||
```
|
||||
HTTP → Caddy (:80) → /worker.php → FrankenPHP worker loop
|
||||
Kernel::boot() [once on startup]
|
||||
→ Environment::detect() → Config::load() → ContainerFactory::build()
|
||||
loop:
|
||||
→ HttpApplication::handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER)
|
||||
→ Request::fromGlobals()
|
||||
→ OPTIONS? → CORS headers + 204 (no routing)
|
||||
→ Router::match() → 404 / 405 / RouteMatch
|
||||
→ MiddlewarePipeline::run()
|
||||
→ Controller::__invoke(Request): Response
|
||||
→ Response + CORS headers → emit()
|
||||
→ Application::terminate()
|
||||
→ gc_collect_cycles()
|
||||
```
|
||||
|
||||
**Key components:**
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `src/Kernel.php` | One-time bootstrap |
|
||||
| `src/Http/` | `HttpApplication`, `Request`, `Response`, `Router`, `RouteDefinition`, `MiddlewarePipeline`, `HttpException` |
|
||||
| `src/Console/` | `ConsoleApplication`, `ConsoleRouter`, `ConsoleInput`, `ConsoleOutput`, `ConsoleDefinition` |
|
||||
| `src/Auth/` | `JwtService`, `AuthMiddleware`, `AuthContext`, `AuthException`, `UserProviderInterface` |
|
||||
| `src/Log/` | `StdoutLogger`, `FileLogger`, `CompositeLogger`, `NullLogger` |
|
||||
| `src/Orm/` | `AbstractMongoRepository`, `MongoHydrator`, `EntityMap`, `IdGenerator`, attributes |
|
||||
| `src/Model/` | Marker interfaces: `Entity`, `MongoEntity`, `SqlEntity`, `Dto` |
|
||||
| `src/ExceptionHandler.php` | Catches `Throwable` escaping the worker loop |
|
||||
| `src/Config.php` | Static config loader: `Config::get('section.key')` |
|
||||
| `src/ContainerFactory.php` | Builds PHP-DI container from `config/services.php` |
|
||||
|
||||
## HTTP
|
||||
|
||||
```php
|
||||
// RouteDefinition: method, path, controller class, middleware array
|
||||
new RouteDefinition('GET', '/users/{id}', GetUserController::class, [AuthMiddleware::class])
|
||||
|
||||
// Controller — invokable, resolved via DI
|
||||
final class GetUserController {
|
||||
public function __invoke(Request $request): Response {
|
||||
$id = $request->pathParams['id']; // path param
|
||||
$user = $request->get('auth')->user; // from AuthMiddleware
|
||||
return Response::json([...]);
|
||||
}
|
||||
}
|
||||
|
||||
// Throw from anywhere — caught centrally
|
||||
throw new HttpException('Forbidden', 403);
|
||||
```
|
||||
|
||||
## Auth
|
||||
|
||||
`AuthMiddleware` requires `UserProviderInterface` to be bound in DI:
|
||||
```php
|
||||
// UserProviderInterface: findById(string $id): object
|
||||
// In app's config/services.php:
|
||||
UserProviderInterface::class => fn($c) => $c->get(UserRepository::class),
|
||||
```
|
||||
|
||||
`JwtService::issue(string $userId): string` — issues a JWT. Accepts only a string ID, no dependency on app models.
|
||||
|
||||
## Logging
|
||||
|
||||
```php
|
||||
// Inject LoggerInterface via DI constructor
|
||||
$this->logger->error('Something failed', ['exception' => $e, 'path' => $request->path]);
|
||||
```
|
||||
|
||||
Output: JSON to stdout — `ts`, `level`, `channel`, `message`, `context`.
|
||||
Level: `LOG_LEVEL` env var (default `debug`).
|
||||
File logging: set `LOG_FILE=/path/to/file.log` — `FileLogger` activates automatically.
|
||||
To add a backend: extend the `array_filter([...])` in the `LoggerInterface` factory in `config/services.php`.
|
||||
|
||||
## ORM
|
||||
|
||||
Entities implement `MongoEntity`, use PHP 8 attributes:
|
||||
```php
|
||||
#[Collection(name: 'users')]
|
||||
final class User implements MongoEntity {
|
||||
public function __construct(
|
||||
#[Id] public readonly string $id,
|
||||
#[Field] public readonly string $email,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
Repository extends `AbstractMongoRepository`, declare `#[ForEntity(Foo::class)]` on the class.
|
||||
Implement typed `save(Foo $e): Foo { return $this->persist($e); }` — not an override of the protected `persist()`.
|
||||
`#[Id]` maps to a custom field (`id`), not MongoDB's `_id` (which remains a native ObjectId).
|
||||
`array` fields (e.g. `string[]`) are hydrated automatically — BSONArray is converted transparently.
|
||||
|
||||
## Code Style
|
||||
|
||||
EditorConfig enforces:
|
||||
- PHP: UTF-8, LF, 4-space indent, 120-char line limit (PSR-12)
|
||||
- JS/TS: 2-space indent, 100-char line limit
|
||||
- Templates (Blade/Twig), YAML, JSON, Docker: 2-space indent
|
||||
18
composer.json
Normal file
18
composer.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "pronchev/pinecore",
|
||||
"description": "Minimal PHP framework for FrankenPHP long-running workers",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php-di/php-di": "^7.0",
|
||||
"mongodb/mongodb": "^2.2.0",
|
||||
"lcobucci/jwt": "^5.0",
|
||||
"psr/log": "^3.0",
|
||||
"psr/clock": "^1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {"Pronchev\\Pinecore\\": "src/"},
|
||||
"files": ["src/helpers.php"]
|
||||
}
|
||||
}
|
||||
591
composer.lock
generated
Normal file
591
composer.lock
generated
Normal file
@@ -0,0 +1,591 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "6f2543dce6a81abc0964ab4b314a0f3f",
|
||||
"packages": [
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
"version": "v2.0.10",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/serializable-closure.git",
|
||||
"reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669",
|
||||
"reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
|
||||
"nesbot/carbon": "^2.67|^3.0",
|
||||
"pestphp/pest": "^2.36|^3.0|^4.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\SerializableClosure\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
},
|
||||
{
|
||||
"name": "Nuno Maduro",
|
||||
"email": "nuno@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.",
|
||||
"keywords": [
|
||||
"closure",
|
||||
"laravel",
|
||||
"serializable"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/serializable-closure/issues",
|
||||
"source": "https://github.com/laravel/serializable-closure"
|
||||
},
|
||||
"time": "2026-02-20T19:59:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/jwt",
|
||||
"version": "5.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/lcobucci/jwt.git",
|
||||
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
|
||||
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-openssl": "*",
|
||||
"ext-sodium": "*",
|
||||
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"psr/clock": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"infection/infection": "^0.29",
|
||||
"lcobucci/clock": "^3.2",
|
||||
"lcobucci/coding-standard": "^11.0",
|
||||
"phpbench/phpbench": "^1.2",
|
||||
"phpstan/extension-installer": "^1.2",
|
||||
"phpstan/phpstan": "^1.10.7",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.1.3",
|
||||
"phpstan/phpstan-phpunit": "^1.3.10",
|
||||
"phpstan/phpstan-strict-rules": "^1.5.0",
|
||||
"phpunit/phpunit": "^11.1"
|
||||
},
|
||||
"suggest": {
|
||||
"lcobucci/clock": ">= 3.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Lcobucci\\JWT\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Luís Cobucci",
|
||||
"email": "lcobucci@gmail.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
|
||||
"keywords": [
|
||||
"JWS",
|
||||
"jwt"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/lcobucci/jwt/issues",
|
||||
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/lcobucci",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/lcobucci",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-17T11:30:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mongodb/mongodb",
|
||||
"version": "2.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mongodb/mongo-php-library.git",
|
||||
"reference": "bbb13f969e37e047fd822527543df55fdc1c9298"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/bbb13f969e37e047fd822527543df55fdc1c9298",
|
||||
"reference": "bbb13f969e37e047fd822527543df55fdc1c9298",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.0",
|
||||
"ext-mongodb": "^2.2",
|
||||
"php": "^8.1",
|
||||
"psr/log": "^1.1.4|^2|^3",
|
||||
"symfony/polyfill-php85": "^1.32"
|
||||
},
|
||||
"replace": {
|
||||
"mongodb/builder": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^12.0",
|
||||
"phpunit/phpunit": "^10.5.35",
|
||||
"rector/rector": "^2.3.4",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"vimeo/psalm": "~6.14.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"MongoDB\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Andreas Braun",
|
||||
"email": "andreas.braun@mongodb.com"
|
||||
},
|
||||
{
|
||||
"name": "Jeremy Mikola",
|
||||
"email": "jmikola@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Jérôme Tamarelle",
|
||||
"email": "jerome.tamarelle@mongodb.com"
|
||||
}
|
||||
],
|
||||
"description": "MongoDB driver library",
|
||||
"homepage": "https://jira.mongodb.org/browse/PHPLIB",
|
||||
"keywords": [
|
||||
"database",
|
||||
"driver",
|
||||
"mongodb",
|
||||
"persistence"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/mongodb/mongo-php-library/issues",
|
||||
"source": "https://github.com/mongodb/mongo-php-library/tree/2.2.0"
|
||||
},
|
||||
"time": "2026-02-11T11:39:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-di/invoker",
|
||||
"version": "2.3.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-DI/Invoker.git",
|
||||
"reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1",
|
||||
"reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.3",
|
||||
"psr/container": "^1.0|^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"athletic/athletic": "~0.1.8",
|
||||
"mnapoli/hard-mode": "~0.3.0",
|
||||
"phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Invoker\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Generic and extensible callable invoker",
|
||||
"homepage": "https://github.com/PHP-DI/Invoker",
|
||||
"keywords": [
|
||||
"callable",
|
||||
"dependency",
|
||||
"dependency-injection",
|
||||
"injection",
|
||||
"invoke",
|
||||
"invoker"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHP-DI/Invoker/issues",
|
||||
"source": "https://github.com/PHP-DI/Invoker/tree/2.3.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/mnapoli",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-30T10:22:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-di/php-di",
|
||||
"version": "7.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-DI/PHP-DI.git",
|
||||
"reference": "f88054cc052e40dbe7b383c8817c19442d480352"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352",
|
||||
"reference": "f88054cc052e40dbe7b383c8817c19442d480352",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"laravel/serializable-closure": "^1.0 || ^2.0",
|
||||
"php": ">=8.0",
|
||||
"php-di/invoker": "^2.0",
|
||||
"psr/container": "^1.1 || ^2.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/container-implementation": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3",
|
||||
"friendsofphp/proxy-manager-lts": "^1",
|
||||
"mnapoli/phpunit-easymock": "^1.3",
|
||||
"phpunit/phpunit": "^9.6 || ^10 || ^11",
|
||||
"vimeo/psalm": "^5|^6"
|
||||
},
|
||||
"suggest": {
|
||||
"friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"DI\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "The dependency injection container for humans",
|
||||
"homepage": "https://php-di.org/",
|
||||
"keywords": [
|
||||
"PSR-11",
|
||||
"container",
|
||||
"container-interop",
|
||||
"dependency injection",
|
||||
"di",
|
||||
"ioc",
|
||||
"psr11"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHP-DI/PHP-DI/issues",
|
||||
"source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/mnapoli",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/php-di/php-di",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-16T11:10:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
"version": "1.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/clock.git",
|
||||
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
|
||||
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.0 || ^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Clock\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for reading the clock.",
|
||||
"homepage": "https://github.com/php-fig/clock",
|
||||
"keywords": [
|
||||
"clock",
|
||||
"now",
|
||||
"psr",
|
||||
"psr-20",
|
||||
"time"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/php-fig/clock/issues",
|
||||
"source": "https://github.com/php-fig/clock/tree/1.0.0"
|
||||
},
|
||||
"time": "2022-11-25T14:36:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/container",
|
||||
"version": "2.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/container.git",
|
||||
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
|
||||
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.4.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Container\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common Container Interface (PHP FIG PSR-11)",
|
||||
"homepage": "https://github.com/php-fig/container",
|
||||
"keywords": [
|
||||
"PSR-11",
|
||||
"container",
|
||||
"container-interface",
|
||||
"container-interop",
|
||||
"psr"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/php-fig/container/issues",
|
||||
"source": "https://github.com/php-fig/container/tree/2.0.2"
|
||||
},
|
||||
"time": "2021-11-05T16:47:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/log",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/log.git",
|
||||
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
|
||||
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Log\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for logging libraries",
|
||||
"homepage": "https://github.com/php-fig/log",
|
||||
"keywords": [
|
||||
"log",
|
||||
"psr",
|
||||
"psr-3"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/log/tree/3.0.2"
|
||||
},
|
||||
"time": "2024-09-11T13:17:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php85",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php85.git",
|
||||
"reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
|
||||
"reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Php85\\": ""
|
||||
},
|
||||
"classmap": [
|
||||
"Resources/stubs"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-06-23T16:12:55+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
10
src/Auth/AuthContext.php
Normal file
10
src/Auth/AuthContext.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Auth;
|
||||
|
||||
final class AuthContext
|
||||
{
|
||||
public function __construct(
|
||||
public readonly object $user,
|
||||
) {}
|
||||
}
|
||||
7
src/Auth/AuthException.php
Normal file
7
src/Auth/AuthException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Auth;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class AuthException extends RuntimeException {}
|
||||
38
src/Auth/AuthMiddleware.php
Normal file
38
src/Auth/AuthMiddleware.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Auth;
|
||||
|
||||
use Pronchev\Pinecore\Http\MiddlewareInterface;
|
||||
use Pronchev\Pinecore\Http\Request;
|
||||
|
||||
final class AuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly JwtService $jwt,
|
||||
private readonly UserProviderInterface $users,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolves the JWT from the Authorization header and injects AuthContext into the request.
|
||||
*
|
||||
* @throws AuthException if the token is missing, invalid, or the user does not exist
|
||||
*/
|
||||
public function process(Request $request): Request
|
||||
{
|
||||
$header = $request->headers['authorization'] ?? '';
|
||||
|
||||
if (!str_starts_with($header, 'Bearer ')) {
|
||||
throw new AuthException('Missing or malformed Authorization header');
|
||||
}
|
||||
|
||||
$tokenString = substr($header, 7);
|
||||
$userId = $this->jwt->verify($tokenString);
|
||||
$user = $this->users->findById($userId);
|
||||
|
||||
if ($user === null) {
|
||||
throw new AuthException('User not found');
|
||||
}
|
||||
|
||||
return $request->withContext('auth', new AuthContext($user));
|
||||
}
|
||||
}
|
||||
69
src/Auth/JwtService.php
Normal file
69
src/Auth/JwtService.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Auth;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\Validation\Constraint\SignedWith;
|
||||
use Lcobucci\JWT\Validation\Constraint\StrictValidAt;
|
||||
use Pronchev\Pinecore\Config;
|
||||
use Psr\Clock\ClockInterface;
|
||||
|
||||
final class JwtService
|
||||
{
|
||||
private readonly Configuration $jwtConfig;
|
||||
private readonly int $accessTtl;
|
||||
|
||||
public function __construct(Config $config)
|
||||
{
|
||||
$secret = $config->get('jwt.secret');
|
||||
if ($secret === '') {
|
||||
throw new \RuntimeException('JWT_SECRET is not configured');
|
||||
}
|
||||
|
||||
$this->accessTtl = $config->get('jwt.access_ttl');
|
||||
|
||||
$signer = new Sha256();
|
||||
$key = InMemory::plainText($secret);
|
||||
|
||||
$this->jwtConfig = Configuration::forSymmetricSigner($signer, $key);
|
||||
$this->jwtConfig->setValidationConstraints(
|
||||
new SignedWith($signer, $key),
|
||||
new StrictValidAt(new class implements ClockInterface {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public function issue(string $userId): string
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$token = $this->jwtConfig->builder()
|
||||
->issuedAt($now)
|
||||
->expiresAt($now->modify("+{$this->accessTtl} seconds"))
|
||||
->relatedTo($userId)
|
||||
->getToken($this->jwtConfig->signer(), $this->jwtConfig->signingKey());
|
||||
|
||||
return $token->toString();
|
||||
}
|
||||
|
||||
public function verify(string $tokenString): string
|
||||
{
|
||||
try {
|
||||
$token = $this->jwtConfig->parser()->parse($tokenString);
|
||||
} catch (\Throwable) {
|
||||
throw new AuthException('Invalid token');
|
||||
}
|
||||
|
||||
if (!$this->jwtConfig->validator()->validate($token, ...$this->jwtConfig->validationConstraints())) {
|
||||
throw new AuthException('Token validation failed');
|
||||
}
|
||||
|
||||
return $token->claims()->get('sub');
|
||||
}
|
||||
}
|
||||
8
src/Auth/UserProviderInterface.php
Normal file
8
src/Auth/UserProviderInterface.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Auth;
|
||||
|
||||
interface UserProviderInterface
|
||||
{
|
||||
public function findById(string $id): ?object;
|
||||
}
|
||||
40
src/Command/ServerStartCommand.php
Normal file
40
src/Command/ServerStartCommand.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Command;
|
||||
|
||||
use Pronchev\Pinecore\ExceptionHandler;
|
||||
use Pronchev\Pinecore\Http\HttpApplication;
|
||||
use Pronchev\Pinecore\Console\ConsoleInput;
|
||||
use Pronchev\Pinecore\Console\ConsoleOutput;
|
||||
|
||||
final class ServerStartCommand
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HttpApplication $app,
|
||||
private readonly ExceptionHandler $exceptionHandler,
|
||||
) {}
|
||||
|
||||
public function __invoke(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 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);
|
||||
}
|
||||
});
|
||||
|
||||
$this->app->terminate();
|
||||
gc_collect_cycles();
|
||||
|
||||
if (!$keepRunning) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
97
src/Config.php
Normal file
97
src/Config.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore;
|
||||
|
||||
class Config
|
||||
{
|
||||
private array $data;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public static function load(string $configDir, Environment $env): self
|
||||
{
|
||||
$base = self::loadDir($configDir);
|
||||
$envOverride = self::loadEnvOverride($configDir . '/env', $env->name());
|
||||
|
||||
return new self(self::merge($base, $envOverride));
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$parts = explode('.', $key);
|
||||
$value = $this->data;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (!is_array($value) || !array_key_exists($part, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$part];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function loadDir(string $dir): array
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach (glob($dir . '/*.php') as $file) {
|
||||
$key = basename($file, '.php');
|
||||
$result[$key] = require $file;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function loadEnvOverride(string $envDir, string $envName): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
$envFile = $envDir . '/' . $envName . '.php';
|
||||
if (file_exists($envFile)) {
|
||||
$files[] = require $envFile;
|
||||
}
|
||||
|
||||
$localFile = $envDir . '/local.php';
|
||||
if (file_exists($localFile)) {
|
||||
$files[] = require $localFile;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($files as $override) {
|
||||
$result = self::merge($result, $override);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function merge(array $base, array $override): array
|
||||
{
|
||||
foreach ($override as $key => $value) {
|
||||
if (
|
||||
is_array($value)
|
||||
&& array_key_exists($key, $base)
|
||||
&& is_array($base[$key])
|
||||
&& !self::isList($value)
|
||||
&& !self::isList($base[$key])
|
||||
) {
|
||||
$base[$key] = self::merge($base[$key], $value);
|
||||
} else {
|
||||
$base[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private static function isList(array $arr): bool
|
||||
{
|
||||
return array_values($arr) === $arr;
|
||||
}
|
||||
}
|
||||
129
src/Console/ConsoleApplication.php
Normal file
129
src/Console/ConsoleApplication.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Console;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
final class ConsoleApplication
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContainerInterface $container,
|
||||
private readonly ConsoleRouter $router,
|
||||
) {}
|
||||
|
||||
public function run(array $argv): int
|
||||
{
|
||||
$commandStr = $argv[1] ?? null;
|
||||
|
||||
if ($commandStr === null || $commandStr === 'help') {
|
||||
$this->printHelp($argv[2] ?? null);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$match = $this->router->match($commandStr);
|
||||
|
||||
if (!$match->found) {
|
||||
fwrite(STDERR, "Unknown command: {$commandStr}" . PHP_EOL . PHP_EOL);
|
||||
$this->printHelp();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$input = ConsoleInput::parse(
|
||||
$commandStr,
|
||||
$match->pathParams,
|
||||
array_slice($argv, 2),
|
||||
$match->definition->options,
|
||||
);
|
||||
$output = new ConsoleOutput();
|
||||
$handler = $this->container->get($match->definition->handler);
|
||||
|
||||
($handler)($input, $output);
|
||||
|
||||
return $output->getExitCode();
|
||||
}
|
||||
|
||||
private function printHelp(?string $signature = null): void
|
||||
{
|
||||
if ($signature !== null) {
|
||||
$this->printCommandHelp($signature);
|
||||
return;
|
||||
}
|
||||
|
||||
$commands = $this->router->all();
|
||||
|
||||
echo 'Available commands:' . PHP_EOL . PHP_EOL;
|
||||
|
||||
$maxLen = max(array_map(static fn($c) => strlen($c->signature), $commands));
|
||||
|
||||
foreach ($commands as $cmd) {
|
||||
$opts = $this->formatOptionsHint($cmd->options);
|
||||
$suffix = $opts !== '' ? " {$opts}" : '';
|
||||
printf(" %-{$maxLen}s %s%s%s", $cmd->signature, $cmd->description, $suffix, PHP_EOL);
|
||||
}
|
||||
|
||||
echo PHP_EOL;
|
||||
echo 'Usage: bin/console <command> [options]' . PHP_EOL;
|
||||
echo ' bin/console help <command>' . PHP_EOL;
|
||||
}
|
||||
|
||||
private function printCommandHelp(string $signature): void
|
||||
{
|
||||
$match = $this->router->match($signature);
|
||||
|
||||
// Try matching the signature pattern itself (without values)
|
||||
$found = null;
|
||||
foreach ($this->router->all() as $cmd) {
|
||||
if ($cmd->signature === $signature) {
|
||||
$found = $cmd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($found === null && $match->found) {
|
||||
$found = $match->definition;
|
||||
}
|
||||
|
||||
if ($found === null) {
|
||||
fwrite(STDERR, "Unknown command: {$signature}" . PHP_EOL);
|
||||
return;
|
||||
}
|
||||
|
||||
echo PHP_EOL;
|
||||
echo " {$found->signature} — {$found->description}" . PHP_EOL;
|
||||
|
||||
// Show path params extracted from signature
|
||||
preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', $found->signature, $m);
|
||||
if ($m[1] !== []) {
|
||||
echo PHP_EOL . ' Path params:' . PHP_EOL;
|
||||
foreach ($m[1] as $param) {
|
||||
printf(" %-16s(part of command)%s", $param, PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
if ($found->options !== []) {
|
||||
echo PHP_EOL . ' Options:' . PHP_EOL;
|
||||
foreach ($found->options as $opt) {
|
||||
$isFlag = $opt->default === false;
|
||||
$nameStr = $isFlag ? "--{$opt->name}" : "--{$opt->name}=<...>";
|
||||
$defaultStr = $opt->default === false
|
||||
? ''
|
||||
: ($opt->default === null ? ' (default: none)' : " (default: {$opt->default})");
|
||||
printf(" %-22s%s%s%s", $nameStr, $opt->description, $defaultStr, PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
echo PHP_EOL;
|
||||
}
|
||||
|
||||
/** @param list<OptionDefinition> $options */
|
||||
private function formatOptionsHint(array $options): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($options as $opt) {
|
||||
$parts[] = $opt->default === false
|
||||
? "[--{$opt->name}]"
|
||||
: "[--{$opt->name}=<{$opt->name}>]";
|
||||
}
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
}
|
||||
17
src/Console/ConsoleDefinition.php
Normal file
17
src/Console/ConsoleDefinition.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Console;
|
||||
|
||||
final class ConsoleDefinition
|
||||
{
|
||||
/**
|
||||
* @param class-string $handler
|
||||
* @param list<OptionDefinition> $options
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $signature,
|
||||
public readonly string $handler,
|
||||
public readonly string $description,
|
||||
public readonly array $options = [],
|
||||
) {}
|
||||
}
|
||||
58
src/Console/ConsoleInput.php
Normal file
58
src/Console/ConsoleInput.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Console;
|
||||
|
||||
final class ConsoleInput
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $pathParams segments captured from command signature
|
||||
* @param list<string> $arguments positional arguments (non-option tokens)
|
||||
* @param array<string, mixed> $options --flag → true, --key=value → 'value'
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $command,
|
||||
public readonly array $pathParams,
|
||||
public readonly array $arguments,
|
||||
public readonly array $options,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Parse remaining argv tokens into arguments and options,
|
||||
* applying defaults from the command definition.
|
||||
*
|
||||
* @param list<string> $tokens argv tokens after the command string
|
||||
* @param list<OptionDefinition> $optionDefs
|
||||
*/
|
||||
public static function parse(
|
||||
string $command,
|
||||
array $pathParams,
|
||||
array $tokens,
|
||||
array $optionDefs,
|
||||
): self {
|
||||
$arguments = [];
|
||||
$options = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (str_starts_with($token, '--')) {
|
||||
$raw = substr($token, 2);
|
||||
if (str_contains($raw, '=')) {
|
||||
[$key, $value] = explode('=', $raw, 2);
|
||||
$options[$key] = $value;
|
||||
} else {
|
||||
$options[$raw] = true;
|
||||
}
|
||||
} else {
|
||||
$arguments[] = $token;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply defaults for missing options
|
||||
foreach ($optionDefs as $def) {
|
||||
if (!array_key_exists($def->name, $options)) {
|
||||
$options[$def->name] = $def->default;
|
||||
}
|
||||
}
|
||||
|
||||
return new self($command, $pathParams, $arguments, $options);
|
||||
}
|
||||
}
|
||||
22
src/Console/ConsoleMatch.php
Normal file
22
src/Console/ConsoleMatch.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Console;
|
||||
|
||||
final class ConsoleMatch
|
||||
{
|
||||
private function __construct(
|
||||
public readonly bool $found,
|
||||
public readonly ?ConsoleDefinition $definition,
|
||||
public readonly array $pathParams,
|
||||
) {}
|
||||
|
||||
public static function found(ConsoleDefinition $definition, array $pathParams): self
|
||||
{
|
||||
return new self(true, $definition, $pathParams);
|
||||
}
|
||||
|
||||
public static function notFound(): self
|
||||
{
|
||||
return new self(false, null, []);
|
||||
}
|
||||
}
|
||||
33
src/Console/ConsoleOutput.php
Normal file
33
src/Console/ConsoleOutput.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Console;
|
||||
|
||||
final class ConsoleOutput
|
||||
{
|
||||
private int $exitCode = 0;
|
||||
|
||||
public function write(string $text): void
|
||||
{
|
||||
echo $text;
|
||||
}
|
||||
|
||||
public function writeln(string $line = ''): void
|
||||
{
|
||||
echo $line . PHP_EOL;
|
||||
}
|
||||
|
||||
public function error(string $line): void
|
||||
{
|
||||
fwrite(STDERR, $line . PHP_EOL);
|
||||
}
|
||||
|
||||
public function setExitCode(int $code): void
|
||||
{
|
||||
$this->exitCode = $code;
|
||||
}
|
||||
|
||||
public function getExitCode(): int
|
||||
{
|
||||
return $this->exitCode;
|
||||
}
|
||||
}
|
||||
64
src/Console/ConsoleRouter.php
Normal file
64
src/Console/ConsoleRouter.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Console;
|
||||
|
||||
final class ConsoleRouter
|
||||
{
|
||||
/**
|
||||
* @var list<array{definition: ConsoleDefinition, regex: string, params: list<string>}>
|
||||
*/
|
||||
private array $compiled = [];
|
||||
|
||||
/** @param list<ConsoleDefinition> $commands */
|
||||
public function __construct(array $commands)
|
||||
{
|
||||
foreach ($commands as $command) {
|
||||
$this->compiled[] = $this->compile($command);
|
||||
}
|
||||
}
|
||||
|
||||
public function match(string $commandStr): ConsoleMatch
|
||||
{
|
||||
foreach ($this->compiled as $entry) {
|
||||
if (!preg_match($entry['regex'], $commandStr, $m)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$params = [];
|
||||
foreach ($entry['params'] as $name) {
|
||||
$params[$name] = $m[$name];
|
||||
}
|
||||
|
||||
return ConsoleMatch::found($entry['definition'], $params);
|
||||
}
|
||||
|
||||
return ConsoleMatch::notFound();
|
||||
}
|
||||
|
||||
/** @return list<ConsoleDefinition> */
|
||||
public function all(): array
|
||||
{
|
||||
return array_column($this->compiled, 'definition');
|
||||
}
|
||||
|
||||
/** @return array{definition: ConsoleDefinition, regex: string, params: list<string>} */
|
||||
private function compile(ConsoleDefinition $command): array
|
||||
{
|
||||
$params = [];
|
||||
// Escape dots, then replace {param} with named capture groups
|
||||
$pattern = preg_replace_callback(
|
||||
'/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/',
|
||||
static function (array $m) use (&$params): string {
|
||||
$params[] = $m[1];
|
||||
return '(?P<' . $m[1] . '>[^.:]+)';
|
||||
},
|
||||
str_replace('.', '\.', $command->signature),
|
||||
);
|
||||
|
||||
return [
|
||||
'definition' => $command,
|
||||
'regex' => '#^' . $pattern . '$#',
|
||||
'params' => $params,
|
||||
];
|
||||
}
|
||||
}
|
||||
15
src/Console/OptionDefinition.php
Normal file
15
src/Console/OptionDefinition.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Console;
|
||||
|
||||
final class OptionDefinition
|
||||
{
|
||||
/**
|
||||
* @param mixed $default false → boolean flag; null or string → value option
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly string $description,
|
||||
public readonly mixed $default = false,
|
||||
) {}
|
||||
}
|
||||
32
src/ContainerFactory.php
Normal file
32
src/ContainerFactory.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
|
||||
class ContainerFactory
|
||||
{
|
||||
public static function build(Environment $env, Config $config, string $basePath): Container
|
||||
{
|
||||
$builder = new ContainerBuilder();
|
||||
|
||||
if ($env->isProd()) {
|
||||
$cacheDir = $basePath . '/var/cache/prod';
|
||||
if (!is_dir($cacheDir)) {
|
||||
mkdir($cacheDir, 0755, true);
|
||||
}
|
||||
$builder->enableCompilation($cacheDir);
|
||||
}
|
||||
|
||||
$builder->useAutowiring(true);
|
||||
|
||||
$servicesFile = $basePath . '/config/services.php';
|
||||
if (file_exists($servicesFile)) {
|
||||
$definitions = require $servicesFile;
|
||||
$definitions($builder, $config, $basePath);
|
||||
}
|
||||
|
||||
return $builder->build();
|
||||
}
|
||||
}
|
||||
33
src/Environment.php
Normal file
33
src/Environment.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore;
|
||||
|
||||
class Environment
|
||||
{
|
||||
private string $name;
|
||||
|
||||
private function __construct(string $name)
|
||||
{
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public static function detect(): self
|
||||
{
|
||||
return new self(getenv('APP_ENV') ?: 'dev');
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function is(string $name): bool
|
||||
{
|
||||
return $this->name === $name;
|
||||
}
|
||||
|
||||
public function isProd(): bool
|
||||
{
|
||||
return $this->name === 'prod';
|
||||
}
|
||||
}
|
||||
17
src/ExceptionHandler.php
Normal file
17
src/ExceptionHandler.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class ExceptionHandler
|
||||
{
|
||||
public function __construct(private readonly LoggerInterface $logger) {}
|
||||
|
||||
public function handleException(\Throwable $exception): void
|
||||
{
|
||||
$this->logger->critical('Unhandled exception in worker loop', [
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
}
|
||||
97
src/Http/HttpApplication.php
Normal file
97
src/Http/HttpApplication.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Http;
|
||||
|
||||
use Pronchev\Pinecore\Auth\AuthException;
|
||||
use Pronchev\Pinecore\Config;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class HttpApplication
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContainerInterface $container,
|
||||
private readonly Router $router,
|
||||
private readonly MiddlewarePipeline $pipeline,
|
||||
private readonly Config $config,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function handleRequest(array $get, array $post, array $cookie, array $files, array $server): void
|
||||
{
|
||||
$rawBody = (string) file_get_contents('php://input');
|
||||
$request = Request::fromGlobals($get, $cookie, $files, $server, $rawBody);
|
||||
|
||||
// OPTIONS preflight — respond immediately with CORS headers, no routing
|
||||
if ($request->method === 'OPTIONS') {
|
||||
$this->emitCorsHeaders();
|
||||
http_response_code(204);
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->dispatch($request);
|
||||
$response = $response->withHeaders($this->corsHeaders());
|
||||
$response->emit();
|
||||
}
|
||||
|
||||
private function dispatch(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$match = $this->router->match($request->method, $request->path);
|
||||
|
||||
if ($match->methodNotAllowed) {
|
||||
return Response::error('Method Not Allowed', 405)
|
||||
->withHeader('Allow', implode(', ', $match->allowedMethods));
|
||||
}
|
||||
|
||||
if (!$match->found) {
|
||||
return Response::error('Not Found', 404);
|
||||
}
|
||||
|
||||
$request = $request->withPathParams($match->pathParams);
|
||||
$request = $this->pipeline->run($request, $match->definition->middleware);
|
||||
$controller = $this->container->get($match->definition->controller);
|
||||
|
||||
return ($controller)($request);
|
||||
|
||||
} catch (AuthException $e) {
|
||||
return Response::error($e->getMessage(), 401);
|
||||
} catch (HttpException $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode());
|
||||
} catch (\JsonException) {
|
||||
return Response::error('Invalid JSON body', 400);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Unhandled exception during dispatch', [
|
||||
'exception' => $e,
|
||||
'method' => $request->method,
|
||||
'path' => $request->path,
|
||||
]);
|
||||
return Response::error('Internal Server Error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function terminate(): void
|
||||
{
|
||||
// Завершение работы приложения. Закрытие соединений, ресурсов и.т.п.
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function corsHeaders(): array
|
||||
{
|
||||
$cors = $this->config->get('app.cors');
|
||||
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->config->get('app.cors');
|
||||
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']);
|
||||
}
|
||||
}
|
||||
11
src/Http/HttpException.php
Normal file
11
src/Http/HttpException.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Http;
|
||||
|
||||
final class HttpException extends \RuntimeException
|
||||
{
|
||||
public function __construct(string $message, int $statusCode)
|
||||
{
|
||||
parent::__construct($message, $statusCode);
|
||||
}
|
||||
}
|
||||
14
src/Http/MiddlewareInterface.php
Normal file
14
src/Http/MiddlewareInterface.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Http;
|
||||
|
||||
interface MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* Process the request and return an enriched (or unchanged) request.
|
||||
*
|
||||
* Implementations may throw AuthException or HttpException to short-circuit
|
||||
* the request — these are caught centrally in HttpApplication::dispatch().
|
||||
*/
|
||||
public function process(Request $request): Request;
|
||||
}
|
||||
25
src/Http/MiddlewarePipeline.php
Normal file
25
src/Http/MiddlewarePipeline.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Http;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
final class MiddlewarePipeline
|
||||
{
|
||||
public function __construct(private readonly ContainerInterface $container) {}
|
||||
|
||||
/**
|
||||
* Run the given middleware classes in order, passing the request through each.
|
||||
*
|
||||
* @param list<class-string<MiddlewareInterface>> $middlewareClasses
|
||||
*/
|
||||
public function run(Request $request, array $middlewareClasses): Request
|
||||
{
|
||||
foreach ($middlewareClasses as $class) {
|
||||
/** @var MiddlewareInterface $middleware */
|
||||
$middleware = $this->container->get($class);
|
||||
$request = $middleware->process($request);
|
||||
}
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
93
src/Http/Request.php
Normal file
93
src/Http/Request.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Http;
|
||||
|
||||
final class Request
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $query
|
||||
* @param array<string, string> $pathParams
|
||||
* @param array<string, string> $headers lowercase-hyphen keys (e.g. 'content-type', 'authorization')
|
||||
* @param array<string, mixed> $body decoded JSON body
|
||||
* @param array<string, string> $cookies $_COOKIE
|
||||
* @param array<string, mixed> $files $_FILES
|
||||
* @param array<string, mixed> $context middleware-injected values (e.g. AuthContext)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $method,
|
||||
public readonly string $path,
|
||||
public readonly array $query,
|
||||
public readonly array $pathParams,
|
||||
public readonly array $headers,
|
||||
private readonly array $body,
|
||||
public readonly array $cookies,
|
||||
public readonly array $files,
|
||||
private array $context = [],
|
||||
) {}
|
||||
|
||||
public function body(): array
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->context[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function withContext(string $key, mixed $value): self
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->context[$key] = $value;
|
||||
return $clone;
|
||||
}
|
||||
|
||||
public function withPathParams(array $params): self
|
||||
{
|
||||
return new self(
|
||||
$this->method,
|
||||
$this->path,
|
||||
$this->query,
|
||||
$params,
|
||||
$this->headers,
|
||||
$this->body,
|
||||
$this->cookies,
|
||||
$this->files,
|
||||
$this->context,
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromGlobals(
|
||||
array $get, array $cookies, array $files, array $server, string $rawBody
|
||||
): self {
|
||||
$method = strtoupper($server['REQUEST_METHOD'] ?? 'GET');
|
||||
$uri = $server['REQUEST_URI'] ?? '/';
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||
$headers = self::extractHeaders($server);
|
||||
|
||||
$body = [];
|
||||
$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 array<string, string> */
|
||||
private static function extractHeaders(array $server): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($server as $key => $value) {
|
||||
if (str_starts_with($key, 'HTTP_')) {
|
||||
$name = strtolower(str_replace('_', '-', substr($key, 5)));
|
||||
$headers[$name] = $value;
|
||||
}
|
||||
}
|
||||
// CONTENT_TYPE and CONTENT_LENGTH have no HTTP_ prefix in $_SERVER
|
||||
if (isset($server['CONTENT_TYPE'])) {
|
||||
$headers['content-type'] = $server['CONTENT_TYPE'];
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
}
|
||||
50
src/Http/Response.php
Normal file
50
src/Http/Response.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Http;
|
||||
|
||||
final class Response
|
||||
{
|
||||
/** @param array<string, string> $headers */
|
||||
public function __construct(
|
||||
public readonly int $status = 200,
|
||||
public readonly array $headers = [],
|
||||
public readonly string $body = '',
|
||||
) {}
|
||||
|
||||
public static function json(mixed $data, int $status = 200): self
|
||||
{
|
||||
return new self(
|
||||
status: $status,
|
||||
headers: ['Content-Type' => 'application/json'],
|
||||
body: json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
);
|
||||
}
|
||||
|
||||
public static function error(string $message, int $status): self
|
||||
{
|
||||
return self::json(['error' => $message], $status);
|
||||
}
|
||||
|
||||
public function withHeader(string $name, string $value): self
|
||||
{
|
||||
return new self(
|
||||
$this->status,
|
||||
array_merge($this->headers, [$name => $value]),
|
||||
$this->body,
|
||||
);
|
||||
}
|
||||
|
||||
public function withHeaders(array $headers): self
|
||||
{
|
||||
return new self($this->status, array_merge($this->headers, $headers), $this->body);
|
||||
}
|
||||
|
||||
public function emit(): void
|
||||
{
|
||||
http_response_code($this->status);
|
||||
foreach ($this->headers as $name => $value) {
|
||||
header("$name: $value");
|
||||
}
|
||||
echo $this->body;
|
||||
}
|
||||
}
|
||||
17
src/Http/RouteDefinition.php
Normal file
17
src/Http/RouteDefinition.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Http;
|
||||
|
||||
final class RouteDefinition
|
||||
{
|
||||
/**
|
||||
* @param class-string $controller
|
||||
* @param list<class-string> $middleware
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $method,
|
||||
public readonly string $path,
|
||||
public readonly string $controller,
|
||||
public readonly array $middleware = [],
|
||||
) {}
|
||||
}
|
||||
31
src/Http/RouteMatch.php
Normal file
31
src/Http/RouteMatch.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Http;
|
||||
|
||||
final class RouteMatch
|
||||
{
|
||||
/** @param list<string> $allowedMethods populated on 405 */
|
||||
private function __construct(
|
||||
public readonly bool $found,
|
||||
public readonly bool $methodNotAllowed,
|
||||
public readonly ?RouteDefinition $definition,
|
||||
public readonly array $pathParams,
|
||||
public readonly array $allowedMethods = [],
|
||||
) {}
|
||||
|
||||
public static function found(RouteDefinition $definition, array $pathParams): self
|
||||
{
|
||||
return new self(true, false, $definition, $pathParams);
|
||||
}
|
||||
|
||||
/** @param list<string> $allowedMethods */
|
||||
public static function methodNotAllowed(array $allowedMethods): self
|
||||
{
|
||||
return new self(false, true, null, [], $allowedMethods);
|
||||
}
|
||||
|
||||
public static function notFound(): self
|
||||
{
|
||||
return new self(false, false, null, []);
|
||||
}
|
||||
}
|
||||
71
src/Http/Router.php
Normal file
71
src/Http/Router.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Http;
|
||||
|
||||
final class Router
|
||||
{
|
||||
/**
|
||||
* @var list<array{definition: RouteDefinition, regex: string, params: list<string>}>
|
||||
*/
|
||||
private array $compiled = [];
|
||||
|
||||
/** @param list<RouteDefinition> $routes */
|
||||
public function __construct(array $routes)
|
||||
{
|
||||
foreach ($routes as $route) {
|
||||
$this->compiled[] = $this->compile($route);
|
||||
}
|
||||
}
|
||||
|
||||
public function match(string $method, string $path): RouteMatch
|
||||
{
|
||||
$method = strtoupper($method);
|
||||
$allowedMethods = [];
|
||||
|
||||
foreach ($this->compiled as $entry) {
|
||||
if (!preg_match($entry['regex'], $path, $m)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Path matched — collect the allowed method regardless
|
||||
$allowedMethods[] = $entry['definition']->method;
|
||||
|
||||
if ($entry['definition']->method !== $method) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$params = [];
|
||||
foreach ($entry['params'] as $name) {
|
||||
$params[$name] = $m[$name];
|
||||
}
|
||||
|
||||
return RouteMatch::found($entry['definition'], $params);
|
||||
}
|
||||
|
||||
if ($allowedMethods !== []) {
|
||||
return RouteMatch::methodNotAllowed(array_unique($allowedMethods));
|
||||
}
|
||||
|
||||
return RouteMatch::notFound();
|
||||
}
|
||||
|
||||
/** @return array{definition: RouteDefinition, regex: string, params: list<string>} */
|
||||
private function compile(RouteDefinition $route): array
|
||||
{
|
||||
$params = [];
|
||||
$pattern = preg_replace_callback(
|
||||
'/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/',
|
||||
static function (array $m) use (&$params): string {
|
||||
$params[] = $m[1];
|
||||
return '(?P<' . $m[1] . '>[^/]+)';
|
||||
},
|
||||
$route->path,
|
||||
);
|
||||
|
||||
return [
|
||||
'definition' => $route,
|
||||
'regex' => '#^' . $pattern . '$#',
|
||||
'params' => $params,
|
||||
];
|
||||
}
|
||||
}
|
||||
30
src/Kernel.php
Normal file
30
src/Kernel.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore;
|
||||
|
||||
use DI\Container;
|
||||
|
||||
class Kernel
|
||||
{
|
||||
private static ?Container $container = null;
|
||||
|
||||
public static function boot(string $basePath): Container
|
||||
{
|
||||
$env = Environment::detect();
|
||||
$config = Config::load($basePath . '/config', $env);
|
||||
$container = 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;
|
||||
}
|
||||
}
|
||||
19
src/Log/CompositeLogger.php
Normal file
19
src/Log/CompositeLogger.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Log;
|
||||
|
||||
use Psr\Log\AbstractLogger;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class CompositeLogger extends AbstractLogger
|
||||
{
|
||||
/** @param LoggerInterface[] $loggers */
|
||||
public function __construct(private readonly array $loggers) {}
|
||||
|
||||
public function log(mixed $level, string|\Stringable $message, array $context = []): void
|
||||
{
|
||||
foreach ($this->loggers as $logger) {
|
||||
$logger->log($level, $message, $context);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/Log/FileLogger.php
Normal file
88
src/Log/FileLogger.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
10
src/Log/NullLogger.php
Normal file
10
src/Log/NullLogger.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Log;
|
||||
|
||||
use Psr\Log\AbstractLogger;
|
||||
|
||||
final class NullLogger extends AbstractLogger
|
||||
{
|
||||
public function log(mixed $level, string|\Stringable $message, array $context = []): void {}
|
||||
}
|
||||
88
src/Log/StdoutLogger.php
Normal file
88
src/Log/StdoutLogger.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Log;
|
||||
|
||||
use Pronchev\Pinecore\Config;
|
||||
use Psr\Log\AbstractLogger;
|
||||
use Psr\Log\LogLevel;
|
||||
|
||||
final class StdoutLogger extends AbstractLogger
|
||||
{
|
||||
private readonly int $minLevel;
|
||||
private readonly string $channel;
|
||||
/** @var resource */
|
||||
private readonly mixed $stdout;
|
||||
|
||||
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
|
||||
{
|
||||
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->stdout, 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
7
src/Model/Dto.php
Normal file
7
src/Model/Dto.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Model;
|
||||
|
||||
interface Dto
|
||||
{
|
||||
}
|
||||
7
src/Model/Entity.php
Normal file
7
src/Model/Entity.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Model;
|
||||
|
||||
interface Entity
|
||||
{
|
||||
}
|
||||
7
src/Model/MongoEntity.php
Normal file
7
src/Model/MongoEntity.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Model;
|
||||
|
||||
interface MongoEntity extends Entity
|
||||
{
|
||||
}
|
||||
7
src/Model/SqlEntity.php
Normal file
7
src/Model/SqlEntity.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Model;
|
||||
|
||||
interface SqlEntity extends Entity
|
||||
{
|
||||
}
|
||||
87
src/Orm/AbstractMongoRepository.php
Normal file
87
src/Orm/AbstractMongoRepository.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm;
|
||||
|
||||
use MongoDB\Collection;
|
||||
use MongoDB\Database;
|
||||
use Pronchev\Pinecore\Orm\Attributes\ForEntity;
|
||||
|
||||
abstract class AbstractMongoRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Database $db,
|
||||
private readonly MongoHydrator $hydrator,
|
||||
private readonly IdGenerator $ids,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolves the entity class for this repository from the #[ForEntity] attribute.
|
||||
* Result is cached for the worker lifetime.
|
||||
*/
|
||||
final protected function entityClass(): string
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
return $cache[static::class] ??= (function (): string {
|
||||
$attrs = (new \ReflectionClass(static::class))->getAttributes(ForEntity::class);
|
||||
|
||||
if (empty($attrs)) {
|
||||
throw new \LogicException(
|
||||
static::class . ' must declare #[ForEntity(EntityClass::class)]'
|
||||
);
|
||||
}
|
||||
|
||||
return $attrs[0]->newInstance()->class;
|
||||
})();
|
||||
}
|
||||
|
||||
protected function persist(object $entity): object
|
||||
{
|
||||
$map = EntityMap::of($entity::class);
|
||||
$idMeta = $map->idField();
|
||||
$doc = $this->hydrator->dehydrate($entity);
|
||||
|
||||
if ($doc[$idMeta->field] === '') {
|
||||
$doc[$idMeta->field] = $this->ids->generate();
|
||||
}
|
||||
|
||||
$this->collection($entity::class)->replaceOne(
|
||||
[$idMeta->field => $doc[$idMeta->field]],
|
||||
$doc,
|
||||
['upsert' => true],
|
||||
);
|
||||
|
||||
return $this->hydrator->hydrate($entity::class, $doc);
|
||||
}
|
||||
|
||||
public function findById(string $id): ?object
|
||||
{
|
||||
$entityClass = $this->entityClass();
|
||||
$idField = EntityMap::of($entityClass)->idField()->field;
|
||||
$doc = $this->collection($entityClass)->findOne([$idField => $id]);
|
||||
|
||||
return $doc ? $this->hydrator->hydrate($entityClass, $doc) : null;
|
||||
}
|
||||
|
||||
protected function findOneWhere(array $filter): ?object
|
||||
{
|
||||
$entityClass = $this->entityClass();
|
||||
$doc = $this->collection($entityClass)->findOne($filter);
|
||||
|
||||
return $doc ? $this->hydrator->hydrate($entityClass, $doc) : null;
|
||||
}
|
||||
|
||||
public function delete(string $id): void
|
||||
{
|
||||
$entityClass = $this->entityClass();
|
||||
$idField = EntityMap::of($entityClass)->idField()->field;
|
||||
|
||||
$this->collection($entityClass)->deleteOne([$idField => $id]);
|
||||
}
|
||||
|
||||
protected function collection(string $entityClass): Collection
|
||||
{
|
||||
$map = EntityMap::of($entityClass);
|
||||
return $this->db->selectCollection($map->collectionName);
|
||||
}
|
||||
}
|
||||
13
src/Orm/Attributes/Collection.php
Normal file
13
src/Orm/Attributes/Collection.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_CLASS)]
|
||||
final class Collection
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
) {}
|
||||
}
|
||||
10
src/Orm/Attributes/Embedded.php
Normal file
10
src/Orm/Attributes/Embedded.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)]
|
||||
final class Embedded
|
||||
{
|
||||
}
|
||||
13
src/Orm/Attributes/EmbeddedList.php
Normal file
13
src/Orm/Attributes/EmbeddedList.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)]
|
||||
final class EmbeddedList
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $class,
|
||||
) {}
|
||||
}
|
||||
13
src/Orm/Attributes/Field.php
Normal file
13
src/Orm/Attributes/Field.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)]
|
||||
final class Field
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?string $name = null,
|
||||
) {}
|
||||
}
|
||||
9
src/Orm/Attributes/ForEntity.php
Normal file
9
src/Orm/Attributes/ForEntity.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||
final class ForEntity
|
||||
{
|
||||
public function __construct(public readonly string $class) {}
|
||||
}
|
||||
10
src/Orm/Attributes/Id.php
Normal file
10
src/Orm/Attributes/Id.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)]
|
||||
final class Id
|
||||
{
|
||||
}
|
||||
122
src/Orm/EntityMap.php
Normal file
122
src/Orm/EntityMap.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm;
|
||||
|
||||
use Pronchev\Pinecore\Orm\Attributes\Collection;
|
||||
use Pronchev\Pinecore\Orm\Attributes\Embedded;
|
||||
use Pronchev\Pinecore\Orm\Attributes\EmbeddedList;
|
||||
use Pronchev\Pinecore\Orm\Attributes\Field;
|
||||
use Pronchev\Pinecore\Orm\Attributes\Id;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
|
||||
final class EntityMap
|
||||
{
|
||||
/** @var array<string, EntityMetadata> */
|
||||
private static array $cache = [];
|
||||
|
||||
public static function of(string $class): EntityMetadata
|
||||
{
|
||||
if (isset(self::$cache[$class])) {
|
||||
return self::$cache[$class];
|
||||
}
|
||||
|
||||
self::$cache[$class] = self::build($class);
|
||||
|
||||
return self::$cache[$class];
|
||||
}
|
||||
|
||||
private static function build(string $class): EntityMetadata
|
||||
{
|
||||
$ref = new ReflectionClass($class);
|
||||
|
||||
$collectionAttrs = $ref->getAttributes(Collection::class);
|
||||
if (empty($collectionAttrs)) {
|
||||
throw new \LogicException("Class {$class} is missing #[Collection] attribute.");
|
||||
}
|
||||
|
||||
/** @var Collection $collection */
|
||||
$collection = $collectionAttrs[0]->newInstance();
|
||||
$fields = [];
|
||||
|
||||
foreach ($ref->getProperties() as $property) {
|
||||
$fieldMeta = self::buildFieldMetadata($property);
|
||||
if ($fieldMeta !== null) {
|
||||
$fields[] = $fieldMeta;
|
||||
}
|
||||
}
|
||||
|
||||
return new EntityMetadata($collection->name, $fields);
|
||||
}
|
||||
|
||||
private static function buildFieldMetadata(ReflectionProperty $property): ?FieldMetadata
|
||||
{
|
||||
$idAttrs = $property->getAttributes(Id::class);
|
||||
$fieldAttrs = $property->getAttributes(Field::class);
|
||||
$embeddedAttrs = $property->getAttributes(Embedded::class);
|
||||
$embeddedListAttrs = $property->getAttributes(EmbeddedList::class);
|
||||
|
||||
if (!empty($idAttrs)) {
|
||||
return new FieldMetadata(
|
||||
property: $property,
|
||||
field: $property->getName(),
|
||||
isId: true,
|
||||
type: self::typeName($property),
|
||||
isEmbedded: false,
|
||||
isEmbeddedList: false,
|
||||
embeddedClass: null,
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($fieldAttrs)) {
|
||||
/** @var Field $field */
|
||||
$field = $fieldAttrs[0]->newInstance();
|
||||
return new FieldMetadata(
|
||||
property: $property,
|
||||
field: $field->name ?? $property->getName(),
|
||||
isId: false,
|
||||
type: self::typeName($property),
|
||||
isEmbedded: false,
|
||||
isEmbeddedList: false,
|
||||
embeddedClass: null,
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($embeddedAttrs)) {
|
||||
return new FieldMetadata(
|
||||
property: $property,
|
||||
field: $property->getName(),
|
||||
isId: false,
|
||||
type: self::typeName($property),
|
||||
isEmbedded: true,
|
||||
isEmbeddedList: false,
|
||||
embeddedClass: self::typeName($property),
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($embeddedListAttrs)) {
|
||||
/** @var EmbeddedList $embeddedList */
|
||||
$embeddedList = $embeddedListAttrs[0]->newInstance();
|
||||
return new FieldMetadata(
|
||||
property: $property,
|
||||
field: $property->getName(),
|
||||
isId: false,
|
||||
type: 'array',
|
||||
isEmbedded: false,
|
||||
isEmbeddedList: true,
|
||||
embeddedClass: $embeddedList->class,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function typeName(ReflectionProperty $property): string
|
||||
{
|
||||
$type = $property->getType();
|
||||
if ($type === null) {
|
||||
return 'mixed';
|
||||
}
|
||||
return (string) $type;
|
||||
}
|
||||
}
|
||||
24
src/Orm/EntityMetadata.php
Normal file
24
src/Orm/EntityMetadata.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm;
|
||||
|
||||
final class EntityMetadata
|
||||
{
|
||||
/**
|
||||
* @param FieldMetadata[] $fields
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $collectionName,
|
||||
public readonly array $fields,
|
||||
) {}
|
||||
|
||||
public function idField(): FieldMetadata
|
||||
{
|
||||
foreach ($this->fields as $field) {
|
||||
if ($field->isId) {
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
throw new \LogicException('No #[Id] field found in entity metadata.');
|
||||
}
|
||||
}
|
||||
18
src/Orm/FieldMetadata.php
Normal file
18
src/Orm/FieldMetadata.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm;
|
||||
|
||||
use ReflectionProperty;
|
||||
|
||||
final class FieldMetadata
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ReflectionProperty $property,
|
||||
public readonly string $field,
|
||||
public readonly bool $isId,
|
||||
public readonly string $type,
|
||||
public readonly bool $isEmbedded,
|
||||
public readonly bool $isEmbeddedList,
|
||||
public readonly ?string $embeddedClass,
|
||||
) {}
|
||||
}
|
||||
11
src/Orm/IdGenerator.php
Normal file
11
src/Orm/IdGenerator.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm;
|
||||
|
||||
final class IdGenerator
|
||||
{
|
||||
public function generate(): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
82
src/Orm/MongoHydrator.php
Normal file
82
src/Orm/MongoHydrator.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Pronchev\Pinecore\Orm;
|
||||
|
||||
use MongoDB\Model\BSONDocument;
|
||||
|
||||
final class MongoHydrator
|
||||
{
|
||||
/**
|
||||
* Convert a MongoDB document (array or BSONDocument) to a typed entity.
|
||||
*/
|
||||
public function hydrate(string $class, array|BSONDocument $doc): object
|
||||
{
|
||||
$doc = $this->toArray($doc);
|
||||
$map = EntityMap::of($class);
|
||||
$args = [];
|
||||
|
||||
foreach ($map->fields as $field) {
|
||||
$key = $field->field;
|
||||
$propName = $field->property->getName();
|
||||
$value = $doc[$key] ?? null;
|
||||
|
||||
if ($value instanceof \MongoDB\Model\BSONArray) {
|
||||
$value = $value->getArrayCopy();
|
||||
}
|
||||
|
||||
if ($field->isEmbedded && $value !== null) {
|
||||
$value = $this->hydrate($field->embeddedClass, $this->toArray($value));
|
||||
} elseif ($field->isEmbeddedList && is_array($value)) {
|
||||
$value = array_map(
|
||||
fn($item) => $this->hydrate($field->embeddedClass, $this->toArray($item)),
|
||||
$value,
|
||||
);
|
||||
}
|
||||
|
||||
$args[$propName] = $value;
|
||||
}
|
||||
|
||||
return new $class(...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a typed entity to an array suitable for MongoDB storage.
|
||||
*/
|
||||
public function dehydrate(object $entity): array
|
||||
{
|
||||
$map = EntityMap::of($entity::class);
|
||||
$doc = [];
|
||||
|
||||
foreach ($map->fields as $field) {
|
||||
$value = $field->property->getValue($entity);
|
||||
|
||||
if ($field->isEmbedded && $value !== null) {
|
||||
$value = $this->dehydrateEmbedded($value);
|
||||
} elseif ($field->isEmbeddedList && is_array($value)) {
|
||||
$value = array_map(fn($item) => $this->dehydrateEmbedded($item), $value);
|
||||
}
|
||||
|
||||
$doc[$field->field] = $value;
|
||||
}
|
||||
|
||||
return $doc;
|
||||
}
|
||||
|
||||
private function dehydrateEmbedded(object $embedded): array
|
||||
{
|
||||
$result = [];
|
||||
$ref = new \ReflectionClass($embedded);
|
||||
foreach ($ref->getProperties() as $property) {
|
||||
$result[$property->getName()] = $property->getValue($embedded);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function toArray(array|BSONDocument $doc): array
|
||||
{
|
||||
if ($doc instanceof BSONDocument) {
|
||||
return iterator_to_array($doc);
|
||||
}
|
||||
return $doc;
|
||||
}
|
||||
}
|
||||
19
src/helpers.php
Normal file
19
src/helpers.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
if (!function_exists('env')) {
|
||||
function env(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$value = getenv($key);
|
||||
|
||||
if ($value === false) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return match (strtolower($value)) {
|
||||
'true', '(true)' => true,
|
||||
'false', '(false)' => false,
|
||||
'null', '(null)' => null,
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user