From 98a4094c5e44d7eb2e802a4f9fe75331f332d529 Mon Sep 17 00:00:00 2001 From: pronchev Date: Sun, 22 Mar 2026 02:40:42 +0300 Subject: [PATCH] Initial commit Co-Authored-By: Claude Sonnet 4.6 --- .editorconfig | 114 ++++++ .gitignore | 5 + CLAUDE.md | 113 ++++++ composer.json | 18 + composer.lock | 591 ++++++++++++++++++++++++++++ src/Auth/AuthContext.php | 10 + src/Auth/AuthException.php | 7 + src/Auth/AuthMiddleware.php | 38 ++ src/Auth/JwtService.php | 69 ++++ src/Auth/UserProviderInterface.php | 8 + src/Command/ServerStartCommand.php | 40 ++ src/Config.php | 97 +++++ src/Console/ConsoleApplication.php | 129 ++++++ src/Console/ConsoleDefinition.php | 17 + src/Console/ConsoleInput.php | 58 +++ src/Console/ConsoleMatch.php | 22 ++ src/Console/ConsoleOutput.php | 33 ++ src/Console/ConsoleRouter.php | 64 +++ src/Console/OptionDefinition.php | 15 + src/ContainerFactory.php | 32 ++ src/Environment.php | 33 ++ src/ExceptionHandler.php | 17 + src/Http/HttpApplication.php | 97 +++++ src/Http/HttpException.php | 11 + src/Http/MiddlewareInterface.php | 14 + src/Http/MiddlewarePipeline.php | 25 ++ src/Http/Request.php | 93 +++++ src/Http/Response.php | 50 +++ src/Http/RouteDefinition.php | 17 + src/Http/RouteMatch.php | 31 ++ src/Http/Router.php | 71 ++++ src/Kernel.php | 30 ++ src/Log/CompositeLogger.php | 19 + src/Log/FileLogger.php | 88 +++++ src/Log/NullLogger.php | 10 + src/Log/StdoutLogger.php | 88 +++++ src/Model/Dto.php | 7 + src/Model/Entity.php | 7 + src/Model/MongoEntity.php | 7 + src/Model/SqlEntity.php | 7 + src/Orm/AbstractMongoRepository.php | 87 ++++ src/Orm/Attributes/Collection.php | 13 + src/Orm/Attributes/Embedded.php | 10 + src/Orm/Attributes/EmbeddedList.php | 13 + src/Orm/Attributes/Field.php | 13 + src/Orm/Attributes/ForEntity.php | 9 + src/Orm/Attributes/Id.php | 10 + src/Orm/EntityMap.php | 122 ++++++ src/Orm/EntityMetadata.php | 24 ++ src/Orm/FieldMetadata.php | 18 + src/Orm/IdGenerator.php | 11 + src/Orm/MongoHydrator.php | 82 ++++ src/helpers.php | 19 + 53 files changed, 2633 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 src/Auth/AuthContext.php create mode 100644 src/Auth/AuthException.php create mode 100644 src/Auth/AuthMiddleware.php create mode 100644 src/Auth/JwtService.php create mode 100644 src/Auth/UserProviderInterface.php create mode 100644 src/Command/ServerStartCommand.php create mode 100644 src/Config.php create mode 100644 src/Console/ConsoleApplication.php create mode 100644 src/Console/ConsoleDefinition.php create mode 100644 src/Console/ConsoleInput.php create mode 100644 src/Console/ConsoleMatch.php create mode 100644 src/Console/ConsoleOutput.php create mode 100644 src/Console/ConsoleRouter.php create mode 100644 src/Console/OptionDefinition.php create mode 100644 src/ContainerFactory.php create mode 100644 src/Environment.php create mode 100644 src/ExceptionHandler.php create mode 100644 src/Http/HttpApplication.php create mode 100644 src/Http/HttpException.php create mode 100644 src/Http/MiddlewareInterface.php create mode 100644 src/Http/MiddlewarePipeline.php create mode 100644 src/Http/Request.php create mode 100644 src/Http/Response.php create mode 100644 src/Http/RouteDefinition.php create mode 100644 src/Http/RouteMatch.php create mode 100644 src/Http/Router.php create mode 100644 src/Kernel.php create mode 100644 src/Log/CompositeLogger.php create mode 100644 src/Log/FileLogger.php create mode 100644 src/Log/NullLogger.php create mode 100644 src/Log/StdoutLogger.php create mode 100644 src/Model/Dto.php create mode 100644 src/Model/Entity.php create mode 100644 src/Model/MongoEntity.php create mode 100644 src/Model/SqlEntity.php create mode 100644 src/Orm/AbstractMongoRepository.php create mode 100644 src/Orm/Attributes/Collection.php create mode 100644 src/Orm/Attributes/Embedded.php create mode 100644 src/Orm/Attributes/EmbeddedList.php create mode 100644 src/Orm/Attributes/Field.php create mode 100644 src/Orm/Attributes/ForEntity.php create mode 100644 src/Orm/Attributes/Id.php create mode 100644 src/Orm/EntityMap.php create mode 100644 src/Orm/EntityMetadata.php create mode 100644 src/Orm/FieldMetadata.php create mode 100644 src/Orm/IdGenerator.php create mode 100644 src/Orm/MongoHydrator.php create mode 100644 src/helpers.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b7d10f3 --- /dev/null +++ b/.editorconfig @@ -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 # пробелы в конце =
в 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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c0c3f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +/vendor/ +var/ +.claude/ +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..61d962a --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a41cf74 --- /dev/null +++ b/composer.json @@ -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"] + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..30947c7 --- /dev/null +++ b/composer.lock @@ -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" +} diff --git a/src/Auth/AuthContext.php b/src/Auth/AuthContext.php new file mode 100644 index 0000000..a88e5a8 --- /dev/null +++ b/src/Auth/AuthContext.php @@ -0,0 +1,10 @@ +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)); + } +} diff --git a/src/Auth/JwtService.php b/src/Auth/JwtService.php new file mode 100644 index 0000000..d7a07ac --- /dev/null +++ b/src/Auth/JwtService.php @@ -0,0 +1,69 @@ +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'); + } +} diff --git a/src/Auth/UserProviderInterface.php b/src/Auth/UserProviderInterface.php new file mode 100644 index 0000000..f4c6848 --- /dev/null +++ b/src/Auth/UserProviderInterface.php @@ -0,0 +1,8 @@ +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; + } +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..ce7a7a1 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,97 @@ +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; + } +} diff --git a/src/Console/ConsoleApplication.php b/src/Console/ConsoleApplication.php new file mode 100644 index 0000000..d4e6d23 --- /dev/null +++ b/src/Console/ConsoleApplication.php @@ -0,0 +1,129 @@ +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 [options]' . PHP_EOL; + echo ' bin/console help ' . 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 $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); + } +} diff --git a/src/Console/ConsoleDefinition.php b/src/Console/ConsoleDefinition.php new file mode 100644 index 0000000..8400e99 --- /dev/null +++ b/src/Console/ConsoleDefinition.php @@ -0,0 +1,17 @@ + $options + */ + public function __construct( + public readonly string $signature, + public readonly string $handler, + public readonly string $description, + public readonly array $options = [], + ) {} +} diff --git a/src/Console/ConsoleInput.php b/src/Console/ConsoleInput.php new file mode 100644 index 0000000..9dad3c1 --- /dev/null +++ b/src/Console/ConsoleInput.php @@ -0,0 +1,58 @@ + $pathParams segments captured from command signature + * @param list $arguments positional arguments (non-option tokens) + * @param array $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 $tokens argv tokens after the command string + * @param list $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); + } +} diff --git a/src/Console/ConsoleMatch.php b/src/Console/ConsoleMatch.php new file mode 100644 index 0000000..f9f8024 --- /dev/null +++ b/src/Console/ConsoleMatch.php @@ -0,0 +1,22 @@ +exitCode = $code; + } + + public function getExitCode(): int + { + return $this->exitCode; + } +} diff --git a/src/Console/ConsoleRouter.php b/src/Console/ConsoleRouter.php new file mode 100644 index 0000000..0dba1dd --- /dev/null +++ b/src/Console/ConsoleRouter.php @@ -0,0 +1,64 @@ +}> + */ + private array $compiled = []; + + /** @param list $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 */ + public function all(): array + { + return array_column($this->compiled, 'definition'); + } + + /** @return array{definition: ConsoleDefinition, regex: string, params: list} */ + 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, + ]; + } +} diff --git a/src/Console/OptionDefinition.php b/src/Console/OptionDefinition.php new file mode 100644 index 0000000..aae42cd --- /dev/null +++ b/src/Console/OptionDefinition.php @@ -0,0 +1,15 @@ +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(); + } +} diff --git a/src/Environment.php b/src/Environment.php new file mode 100644 index 0000000..2611b41 --- /dev/null +++ b/src/Environment.php @@ -0,0 +1,33 @@ +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'; + } +} diff --git a/src/ExceptionHandler.php b/src/ExceptionHandler.php new file mode 100644 index 0000000..311f46c --- /dev/null +++ b/src/ExceptionHandler.php @@ -0,0 +1,17 @@ +logger->critical('Unhandled exception in worker loop', [ + 'exception' => $exception, + ]); + } +} diff --git a/src/Http/HttpApplication.php b/src/Http/HttpApplication.php new file mode 100644 index 0000000..10f9c81 --- /dev/null +++ b/src/Http/HttpApplication.php @@ -0,0 +1,97 @@ +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 */ + 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']); + } +} diff --git a/src/Http/HttpException.php b/src/Http/HttpException.php new file mode 100644 index 0000000..8239ecb --- /dev/null +++ b/src/Http/HttpException.php @@ -0,0 +1,11 @@ +> $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; + } +} diff --git a/src/Http/Request.php b/src/Http/Request.php new file mode 100644 index 0000000..edb2f99 --- /dev/null +++ b/src/Http/Request.php @@ -0,0 +1,93 @@ + $query + * @param array $pathParams + * @param array $headers lowercase-hyphen keys (e.g. 'content-type', 'authorization') + * @param array $body decoded JSON body + * @param array $cookies $_COOKIE + * @param array $files $_FILES + * @param array $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 */ + 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; + } +} diff --git a/src/Http/Response.php b/src/Http/Response.php new file mode 100644 index 0000000..2f9d8fb --- /dev/null +++ b/src/Http/Response.php @@ -0,0 +1,50 @@ + $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; + } +} diff --git a/src/Http/RouteDefinition.php b/src/Http/RouteDefinition.php new file mode 100644 index 0000000..dcccb19 --- /dev/null +++ b/src/Http/RouteDefinition.php @@ -0,0 +1,17 @@ + $middleware + */ + public function __construct( + public readonly string $method, + public readonly string $path, + public readonly string $controller, + public readonly array $middleware = [], + ) {} +} diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php new file mode 100644 index 0000000..d282758 --- /dev/null +++ b/src/Http/RouteMatch.php @@ -0,0 +1,31 @@ + $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 $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, []); + } +} diff --git a/src/Http/Router.php b/src/Http/Router.php new file mode 100644 index 0000000..3ffcc23 --- /dev/null +++ b/src/Http/Router.php @@ -0,0 +1,71 @@ +}> + */ + private array $compiled = []; + + /** @param list $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} */ + 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, + ]; + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 0000000..cb1907d --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,30 @@ +loggers as $logger) { + $logger->log($level, $message, $context); + } + } +} diff --git a/src/Log/FileLogger.php b/src/Log/FileLogger.php new file mode 100644 index 0000000..b9525c4 --- /dev/null +++ b/src/Log/FileLogger.php @@ -0,0 +1,88 @@ +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, + }; + } +} diff --git a/src/Log/NullLogger.php b/src/Log/NullLogger.php new file mode 100644 index 0000000..513d241 --- /dev/null +++ b/src/Log/NullLogger.php @@ -0,0 +1,10 @@ +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, + }; + } +} diff --git a/src/Model/Dto.php b/src/Model/Dto.php new file mode 100644 index 0000000..0f39a10 --- /dev/null +++ b/src/Model/Dto.php @@ -0,0 +1,7 @@ +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); + } +} diff --git a/src/Orm/Attributes/Collection.php b/src/Orm/Attributes/Collection.php new file mode 100644 index 0000000..b3de8a3 --- /dev/null +++ b/src/Orm/Attributes/Collection.php @@ -0,0 +1,13 @@ + */ + 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; + } +} diff --git a/src/Orm/EntityMetadata.php b/src/Orm/EntityMetadata.php new file mode 100644 index 0000000..1a299dc --- /dev/null +++ b/src/Orm/EntityMetadata.php @@ -0,0 +1,24 @@ +fields as $field) { + if ($field->isId) { + return $field; + } + } + throw new \LogicException('No #[Id] field found in entity metadata.'); + } +} diff --git a/src/Orm/FieldMetadata.php b/src/Orm/FieldMetadata.php new file mode 100644 index 0000000..d3dc9f4 --- /dev/null +++ b/src/Orm/FieldMetadata.php @@ -0,0 +1,18 @@ +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; + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..4f35d6b --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,19 @@ + true, + 'false', '(false)' => false, + 'null', '(null)' => null, + default => $value, + }; + } +}