Initial commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 02:40:42 +03:00
commit 98a4094c5e
53 changed files with 2633 additions and 0 deletions

10
src/Auth/AuthContext.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace Pronchev\Pinecore\Auth;
final class AuthContext
{
public function __construct(
public readonly object $user,
) {}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Pronchev\Pinecore\Auth;
use RuntimeException;
final class AuthException extends RuntimeException {}

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

View File

@@ -0,0 +1,8 @@
<?php
namespace Pronchev\Pinecore\Auth;
interface UserProviderInterface
{
public function findById(string $id): ?object;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

7
src/Model/Entity.php Normal file
View File

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

View File

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

7
src/Model/SqlEntity.php Normal file
View File

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

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

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

View File

@@ -0,0 +1,10 @@
<?php
namespace Pronchev\Pinecore\Orm\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final class Embedded
{
}

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

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

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

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