feat: add comprehensive framework features and deployment improvements
Major additions: - Storage abstraction layer with filesystem and in-memory implementations - Gitea API integration with MCP tools for repository management - Console dialog mode with interactive command execution - WireGuard VPN DNS fix implementation and documentation - HTTP client streaming response support - Router generic result type - Parameter type validator for framework core Framework enhancements: - Console command registry improvements - Console dialog components - Method signature analyzer updates - Route mapper refinements - MCP server and tool mapper updates - Queue job chain and dependency commands - Discovery tokenizer improvements Infrastructure: - Deployment architecture documentation - Ansible playbook updates for WireGuard client regeneration - Production environment configuration updates - Docker Compose local configuration updates - Remove obsolete docker-compose.yml (replaced by environment-specific configs) Documentation: - PERMISSIONS.md for access control guidelines - WireGuard DNS fix implementation details - Console dialog mode usage guide - Deployment architecture overview Testing: - Multi-purpose attribute tests - Gitea Actions integration tests (typed and untyped)
This commit is contained in:
@@ -228,10 +228,24 @@ final readonly class CommandRegistry
|
||||
|
||||
private function normalizeCommandResult($result): ExitCode
|
||||
{
|
||||
// Handle ActionResult (including ConsoleResult)
|
||||
if ($result instanceof \App\Framework\Router\ActionResult) {
|
||||
// If it's already a ConsoleResult, render it and return exit code
|
||||
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
|
||||
// Rendering will be handled by ConsoleApplication::processCommandResult
|
||||
return $result->exitCode;
|
||||
}
|
||||
|
||||
// Convert other ActionResult types to ConsoleResult
|
||||
return $this->convertActionResultToConsoleResult($result)->exitCode;
|
||||
}
|
||||
|
||||
// Legacy ExitCode pattern
|
||||
if ($result instanceof ExitCode) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Legacy int pattern (for backwards compatibility)
|
||||
if (is_int($result)) {
|
||||
try {
|
||||
return ExitCode::from($result);
|
||||
@@ -240,6 +254,7 @@ final readonly class CommandRegistry
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy bool pattern
|
||||
if (is_bool($result)) {
|
||||
return $result ? ExitCode::SUCCESS : ExitCode::GENERAL_ERROR;
|
||||
}
|
||||
@@ -247,6 +262,42 @@ final readonly class CommandRegistry
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ActionResult to ConsoleResult
|
||||
*/
|
||||
private function convertActionResultToConsoleResult(\App\Framework\Router\ActionResult $result): \App\Framework\Console\Result\ConsoleResult
|
||||
{
|
||||
// GenericResult: Use built-in conversion
|
||||
if ($result instanceof \App\Framework\Router\GenericResult) {
|
||||
return $result->toConsoleResult();
|
||||
}
|
||||
|
||||
// Convert JsonResult to TextResult
|
||||
if ($result instanceof \App\Framework\Router\Result\JsonResult) {
|
||||
$json = json_encode($result->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
return \App\Framework\Console\Result\TextResult::info($json);
|
||||
}
|
||||
|
||||
// Convert ToolResult to TextResult
|
||||
if ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) {
|
||||
$message = $result->success
|
||||
? 'Operation completed successfully'
|
||||
: 'Operation failed: ' . ($result->error ?? 'Unknown error');
|
||||
|
||||
$data = $result->data !== null ? json_encode($result->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '';
|
||||
if ($data) {
|
||||
$message .= "\n" . $data;
|
||||
}
|
||||
|
||||
return $result->success
|
||||
? \App\Framework\Console\Result\TextResult::success($message)
|
||||
: \App\Framework\Console\Result\TextResult::error($message);
|
||||
}
|
||||
|
||||
// Default: convert to info text
|
||||
return \App\Framework\Console\Result\TextResult::info('Command executed successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command with automatic parameter resolution
|
||||
*/
|
||||
@@ -278,6 +329,18 @@ final readonly class CommandRegistry
|
||||
|
||||
$result = $method->invokeArgs($instance, $resolvedParams);
|
||||
|
||||
// Handle ActionResult - if it's a ConsoleResult, render it
|
||||
if ($result instanceof \App\Framework\Router\ActionResult) {
|
||||
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
|
||||
$result->render($progressAwareOutput);
|
||||
return $result->exitCode;
|
||||
}
|
||||
// Convert other ActionResult types
|
||||
$consoleResult = $this->convertActionResultToConsoleResult($result);
|
||||
$consoleResult->render($progressAwareOutput);
|
||||
return $consoleResult->exitCode;
|
||||
}
|
||||
|
||||
return $this->normalizeCommandResult($result);
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
*/
|
||||
final readonly class ConsoleDialog
|
||||
{
|
||||
private bool $readlineAvailable = false;
|
||||
private bool $readlineAvailable;
|
||||
|
||||
private CommandSuggestionEngine $suggestionEngine;
|
||||
|
||||
|
||||
@@ -4,12 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Console;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Core\AttributeMapper;
|
||||
use App\Framework\Core\ParameterTypeValidator;
|
||||
use App\Framework\Mcp\McpTool;
|
||||
use App\Framework\Reflection\WrappedReflectionClass;
|
||||
use App\Framework\Reflection\WrappedReflectionMethod;
|
||||
|
||||
final readonly class ConsoleCommandMapper implements AttributeMapper
|
||||
{
|
||||
private ParameterTypeValidator $typeValidator;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->typeValidator = new ParameterTypeValidator();
|
||||
}
|
||||
|
||||
public function getAttributeClass(): string
|
||||
{
|
||||
return ConsoleCommand::class;
|
||||
@@ -21,6 +31,26 @@ final readonly class ConsoleCommandMapper implements AttributeMapper
|
||||
return null; // ConsoleCommand can only be applied to methods
|
||||
}
|
||||
|
||||
// Check if method has multiple attributes (multi-purpose)
|
||||
$hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget);
|
||||
|
||||
// If multi-purpose, validate that all parameters are builtin types
|
||||
if ($hasMultipleAttributes) {
|
||||
$parameters = $reflectionTarget->getParameters()->toArray();
|
||||
$reflectionParameters = [];
|
||||
foreach ($parameters as $param) {
|
||||
$reflectionParameters[] = $param->getType();
|
||||
}
|
||||
|
||||
if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) {
|
||||
// Skip this attribute if parameters are not all builtin
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get other attributes for metadata
|
||||
$otherAttributes = $this->getOtherPurposeAttributes($reflectionTarget);
|
||||
|
||||
return [
|
||||
'attribute_data' => [
|
||||
'name' => $attributeInstance->name,
|
||||
@@ -28,6 +58,53 @@ final readonly class ConsoleCommandMapper implements AttributeMapper
|
||||
],
|
||||
'class' => $reflectionTarget->getDeclaringClass(),
|
||||
'method' => $reflectionTarget->getName(),
|
||||
'multi_purpose' => $hasMultipleAttributes,
|
||||
'other_attributes' => $otherAttributes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route)
|
||||
*/
|
||||
private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool
|
||||
{
|
||||
$attributes = $method->getAttributes();
|
||||
$purposeAttributeCount = 0;
|
||||
|
||||
foreach ($attributes as $attribute) {
|
||||
$attributeName = $attribute->getName();
|
||||
if (in_array($attributeName, [
|
||||
McpTool::class,
|
||||
ConsoleCommand::class,
|
||||
Route::class,
|
||||
], true)) {
|
||||
$purposeAttributeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $purposeAttributeCount > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get other purpose attributes on the same method
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
private function getOtherPurposeAttributes(WrappedReflectionMethod $method): array
|
||||
{
|
||||
$attributes = $method->getAttributes();
|
||||
$otherAttributes = [];
|
||||
|
||||
foreach ($attributes as $attribute) {
|
||||
$attributeName = $attribute->getName();
|
||||
if (in_array($attributeName, [
|
||||
McpTool::class,
|
||||
Route::class,
|
||||
], true)) {
|
||||
$otherAttributes[] = $attributeName;
|
||||
}
|
||||
}
|
||||
|
||||
return $otherAttributes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +330,15 @@ final readonly class MethodSignatureAnalyzer
|
||||
$returnType = $method->getReturnType();
|
||||
if ($returnType instanceof ReflectionNamedType) {
|
||||
$returnTypeName = $returnType->getName();
|
||||
if (! in_array($returnTypeName, ['int', ExitCode::class], true)) {
|
||||
// Accept: int, ExitCode, ActionResult, or array
|
||||
$validReturnTypes = [
|
||||
'int',
|
||||
ExitCode::class,
|
||||
'App\Framework\MagicLinks\Actions\ActionResult',
|
||||
'array'
|
||||
];
|
||||
|
||||
if (! in_array($returnTypeName, $validReturnTypes, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
Dieses Modul bietet eine flexible und benutzerfreundliche Konsolen-Schnittstelle für Ihre PHP-Anwendung. Es ermöglicht die Erstellung von CLI-Befehlen mit einfacher Eingabe- und Ausgabehandlung.
|
||||
|
||||
## Interaktive Modi
|
||||
|
||||
Das Console-Modul bietet zwei interaktive Modi:
|
||||
|
||||
### TUI (Text User Interface) - Standard
|
||||
|
||||
Grafische Terminal-UI mit Maus-Unterstützung und Navigation:
|
||||
|
||||
```bash
|
||||
php console.php # Startet TUI (Standard)
|
||||
php console.php --interactive # Startet TUI explizit
|
||||
php console.php --tui # Startet TUI explizit
|
||||
```
|
||||
|
||||
### Dialog-Modus - AI-Assistent-ähnlich
|
||||
|
||||
Einfache Prompt-Eingabe mit Tab-Completion und History:
|
||||
|
||||
```bash
|
||||
php console.php --dialog # Startet Dialog-Modus
|
||||
php console.php --chat # Startet Dialog-Modus (Alias)
|
||||
```
|
||||
|
||||
**Weitere Informationen**: Siehe [Console Dialog Mode Dokumentation](../../docs/console-dialog-mode.md)
|
||||
|
||||
## Hauptkomponenten
|
||||
|
||||
### ConsoleApplication
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Framework\Console\Result;
|
||||
|
||||
use App\Framework\Console\ConsoleOutputInterface;
|
||||
use App\Framework\Console\ExitCode;
|
||||
use App\Framework\Router\ActionResult;
|
||||
|
||||
/**
|
||||
* Console Result Interface
|
||||
@@ -18,7 +19,7 @@ use App\Framework\Console\ExitCode;
|
||||
* - Rendering logic
|
||||
* - Metadata for testing/introspection
|
||||
*/
|
||||
interface ConsoleResult
|
||||
interface ConsoleResult extends ActionResult
|
||||
{
|
||||
/**
|
||||
* Exit code for this result
|
||||
|
||||
101
src/Framework/Core/ParameterTypeValidator.php
Normal file
101
src/Framework/Core/ParameterTypeValidator.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Core;
|
||||
|
||||
use ReflectionIntersectionType;
|
||||
use ReflectionNamedType;
|
||||
use ReflectionParameter;
|
||||
use ReflectionType;
|
||||
use ReflectionUnionType;
|
||||
|
||||
/**
|
||||
* Validates parameter types to check if they are builtin types
|
||||
*/
|
||||
final readonly class ParameterTypeValidator
|
||||
{
|
||||
/**
|
||||
* Check if a parameter type is a builtin type
|
||||
*/
|
||||
public function isBuiltinType(?\ReflectionType $type): bool
|
||||
{
|
||||
if ($type === null) {
|
||||
return false; // No type = mixed, not allowed
|
||||
}
|
||||
|
||||
if ($type instanceof ReflectionNamedType) {
|
||||
return $this->isBuiltinTypeName($type->getName());
|
||||
}
|
||||
|
||||
if ($type instanceof ReflectionUnionType) {
|
||||
return $this->isBuiltinUnionType($type);
|
||||
}
|
||||
|
||||
if ($type instanceof ReflectionIntersectionType) {
|
||||
return false; // Intersection types are not builtin
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all parameters of a method are builtin types
|
||||
*
|
||||
* @param array<\ReflectionType|null> $types
|
||||
*/
|
||||
public function hasOnlyBuiltinParameters(array $types): bool
|
||||
{
|
||||
foreach ($types as $type) {
|
||||
if (! $this->isBuiltinType($type)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a type name is a builtin type
|
||||
*/
|
||||
private function isBuiltinTypeName(string $typeName): bool
|
||||
{
|
||||
$builtinTypes = [
|
||||
'string',
|
||||
'int',
|
||||
'float',
|
||||
'bool',
|
||||
'array',
|
||||
'null',
|
||||
'mixed',
|
||||
];
|
||||
|
||||
return in_array($typeName, $builtinTypes, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a union type only contains builtin types
|
||||
*/
|
||||
private function isBuiltinUnionType(ReflectionUnionType $type): bool
|
||||
{
|
||||
foreach ($type->getTypes() as $unionType) {
|
||||
if (! $unionType instanceof ReflectionNamedType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$typeName = $unionType->getName();
|
||||
|
||||
// Allow null in unions (e.g., ?string, string|null)
|
||||
if ($typeName === 'null') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->isBuiltinTypeName($typeName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Framework\Core;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Mcp\McpTool;
|
||||
use App\Framework\Reflection\WrappedReflectionClass;
|
||||
use App\Framework\Reflection\WrappedReflectionMethod;
|
||||
use App\Framework\Router\ValueObjects\MethodParameter;
|
||||
@@ -12,6 +14,13 @@ use App\Framework\Router\ValueObjects\ParameterCollection;
|
||||
|
||||
final readonly class RouteMapper implements AttributeMapper
|
||||
{
|
||||
private ParameterTypeValidator $typeValidator;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->typeValidator = new ParameterTypeValidator();
|
||||
}
|
||||
|
||||
public function getAttributeClass(): string
|
||||
{
|
||||
return Route::class;
|
||||
@@ -28,11 +37,44 @@ final readonly class RouteMapper implements AttributeMapper
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if method has multiple attributes (multi-purpose)
|
||||
$hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget);
|
||||
|
||||
// If multi-purpose, validate that all parameters are builtin types
|
||||
if ($hasMultipleAttributes) {
|
||||
$parameters = $reflectionTarget->getParameters()->toArray();
|
||||
$reflectionParameters = [];
|
||||
foreach ($parameters as $param) {
|
||||
$reflectionParameters[] = $param->getType();
|
||||
}
|
||||
|
||||
if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) {
|
||||
// Skip this attribute if parameters are not all builtin
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all non-Route attributes on the method
|
||||
$attributes = [];
|
||||
$otherPurposeAttributes = [];
|
||||
$hasWebhookEndpoint = false;
|
||||
foreach ($reflectionTarget->getAttributes() as $attribute) {
|
||||
if ($attribute->getName() !== Route::class) {
|
||||
$attributes[] = $attribute->getName();
|
||||
$attributeName = $attribute->getName();
|
||||
if ($attributeName !== Route::class) {
|
||||
$attributes[] = $attributeName;
|
||||
|
||||
// Track purpose attributes
|
||||
if (in_array($attributeName, [
|
||||
McpTool::class,
|
||||
ConsoleCommand::class,
|
||||
], true)) {
|
||||
$otherPurposeAttributes[] = $attributeName;
|
||||
}
|
||||
|
||||
// Track WebhookEndpoint attribute
|
||||
if ($attributeName === \App\Framework\Webhook\Attributes\WebhookEndpoint::class) {
|
||||
$hasWebhookEndpoint = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +94,31 @@ final readonly class RouteMapper implements AttributeMapper
|
||||
'parameters' => $parameterCollection->toLegacyArray(), // Backward compatibility
|
||||
'parameter_collection' => $parameterCollection, // New type-safe collection
|
||||
'attributes' => $attributes,
|
||||
'multi_purpose' => $hasMultipleAttributes,
|
||||
'other_attributes' => $otherPurposeAttributes,
|
||||
'has_webhook_endpoint' => $hasWebhookEndpoint,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route)
|
||||
*/
|
||||
private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool
|
||||
{
|
||||
$attributes = $method->getAttributes();
|
||||
$purposeAttributeCount = 0;
|
||||
|
||||
foreach ($attributes as $attribute) {
|
||||
$attributeName = $attribute->getName();
|
||||
if (in_array($attributeName, [
|
||||
McpTool::class,
|
||||
ConsoleCommand::class,
|
||||
Route::class,
|
||||
], true)) {
|
||||
$purposeAttributeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $purposeAttributeCount > 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +284,39 @@ final readonly class ClassName implements Stringable
|
||||
private function isValidClassName(string $className): bool
|
||||
{
|
||||
// Basic validation: should contain only alphanumeric, underscore, and backslash
|
||||
return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff\\\\]*$/', $className) === 1;
|
||||
if (preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff\\\\]*$/', $className) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the short name (last part after backslash)
|
||||
$lastBackslash = strrpos($className, '\\');
|
||||
$shortName = $lastBackslash === false ? $className : substr($className, $lastBackslash + 1);
|
||||
|
||||
// Reject known invalid class names (PHP keywords and common false positives)
|
||||
$invalidNames = [
|
||||
'implementation', 'interface', 'extends', 'implements',
|
||||
'class', 'trait', 'enum', 'namespace', 'use', 'as',
|
||||
'public', 'private', 'protected', 'static', 'final', 'abstract',
|
||||
'function', 'return', 'if', 'else', 'foreach', 'while', 'for',
|
||||
'true', 'false', 'null', 'void', 'mixed', 'array', 'object',
|
||||
'string', 'int', 'float', 'bool', 'resource', 'callable', 'iterable'
|
||||
];
|
||||
|
||||
if (in_array(strtolower($shortName), $invalidNames, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject camelCase names (starting with lowercase) - class names should be PascalCase
|
||||
// camelCase names are almost always methods, functions, or variables, not classes
|
||||
if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '_') && ! str_contains($shortName, '\\')) {
|
||||
// This is camelCase - reject it as a class name
|
||||
// Only allow if it's a known valid exception (very rare in PHP)
|
||||
$validCamelCaseExceptions = [];
|
||||
if (! in_array($shortName, $validCamelCaseExceptions, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ use App\Framework\Database\Driver\Optimization\SQLiteOptimizer;
|
||||
use App\Framework\Database\Profiling\ProfilingDashboard;
|
||||
use App\Framework\Database\Profiling\QueryProfiler;
|
||||
use App\Framework\Database\Profiling\SlowQueryDetector;
|
||||
use App\Framework\Http\Controller;
|
||||
use App\Framework\Http\Response\JsonResponse;
|
||||
use App\Framework\Http\Response\Response;
|
||||
use App\Framework\Http\Response\ViewResponse;
|
||||
@@ -21,7 +20,7 @@ use App\Framework\View\ViewRenderer;
|
||||
/**
|
||||
* Controller for the database performance dashboard
|
||||
*/
|
||||
final readonly class DatabaseDashboardController implements Controller
|
||||
final readonly class DatabaseDashboardController
|
||||
{
|
||||
public function __construct(
|
||||
private DatabaseManager $databaseManager,
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Framework\Database\Monitoring\Dashboard;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Database\DatabaseManager;
|
||||
use App\Framework\Database\Monitoring\Health\DatabaseHealthChecker;
|
||||
use App\Framework\Http\Controller;
|
||||
use App\Framework\Http\Response\JsonResponse;
|
||||
use App\Framework\Http\Response\Response;
|
||||
use App\Framework\Http\Response\ViewResponse;
|
||||
@@ -16,7 +15,7 @@ use App\Framework\View\ViewRenderer;
|
||||
/**
|
||||
* Controller for the database health dashboard
|
||||
*/
|
||||
final readonly class DatabaseHealthController implements Controller
|
||||
final readonly class DatabaseHealthController
|
||||
{
|
||||
public function __construct(
|
||||
private DatabaseManager $databaseManager,
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Framework\Database\Monitoring\Dashboard;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Database\Monitoring\History\QueryHistoryLogger;
|
||||
use App\Framework\Http\Controller;
|
||||
use App\Framework\Http\Response\JsonResponse;
|
||||
use App\Framework\Http\Response\Response;
|
||||
use App\Framework\Http\Response\ViewResponse;
|
||||
@@ -15,7 +14,7 @@ use App\Framework\View\ViewRenderer;
|
||||
/**
|
||||
* Controller for displaying historical query performance data
|
||||
*/
|
||||
final readonly class QueryHistoryController implements Controller
|
||||
final readonly class QueryHistoryController
|
||||
{
|
||||
public function __construct(
|
||||
private QueryHistoryLogger $historyLogger,
|
||||
|
||||
@@ -38,6 +38,7 @@ final readonly class ClassExtractor
|
||||
}
|
||||
|
||||
$classes = $this->tokenizer->extractClasses($content);
|
||||
$fileNamespace = $this->extractFileNamespace($content);
|
||||
$validClassNames = [];
|
||||
|
||||
foreach ($classes as $class) {
|
||||
@@ -52,6 +53,22 @@ final readonly class ClassExtractor
|
||||
continue;
|
||||
}
|
||||
|
||||
// CRITICAL: Validate that the extracted class belongs to this file's namespace
|
||||
// Classes should only be extracted from files where they are declared,
|
||||
// not from files where they are only used (via use statements)
|
||||
if (! $this->belongsToFileNamespace($fqn, $fileNamespace, $class['namespace'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ADDITIONAL SAFETY: Verify that the class is actually declared in this file
|
||||
// Search for "class <name>" or "interface <name>" etc. in the file content
|
||||
// This catches cases where the tokenizer might extract wrong names
|
||||
$shortClassName = $class['name'] ?? null;
|
||||
if ($shortClassName !== null && ! $this->isClassDeclaredInFile($content, $shortClassName, $class['type'] ?? 'class')) {
|
||||
// Class name found but not actually declared in this file - skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$className = ClassName::create($fqn);
|
||||
$validClassNames[] = $className;
|
||||
@@ -96,30 +113,107 @@ final readonly class ClassExtractor
|
||||
|
||||
// Single lowercase word is suspicious - likely a property/method name
|
||||
// But we'll be conservative and only reject known problematic names
|
||||
$knownInvalid = ['state', 'container', 'get', 'set', 'map', 'compile', 'install', 'shouldretry', 'additionaldata'];
|
||||
$knownInvalid = [
|
||||
'state', 'container', 'get', 'set', 'map', 'compile', 'install',
|
||||
'shouldretry', 'additionaldata',
|
||||
// PHP keywords and common false positives from class extraction
|
||||
'implementation', 'interface', 'extends', 'implements'
|
||||
];
|
||||
if (in_array(strtolower($shortName), $knownInvalid, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// camelCase starting with lowercase (likely method names)
|
||||
if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '\\')) {
|
||||
$methodPrefixes = ['get', 'set', 'is', 'has', 'should', 'can', 'will', 'do', 'add', 'remove', 'update', 'delete'];
|
||||
$lowercase = strtolower($shortName);
|
||||
foreach ($methodPrefixes as $prefix) {
|
||||
if (str_starts_with($lowercase, $prefix) && strlen($shortName) > strlen($prefix)) {
|
||||
// Check if it's in our known invalid list
|
||||
$knownInvalid = ['shouldretry', 'additionaldata'];
|
||||
if (in_array($lowercase, $knownInvalid, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// camelCase starting with lowercase (likely method names) - STRICT REJECTION
|
||||
// PHP class names should be PascalCase (start with uppercase)
|
||||
// camelCase names are almost always methods, functions, or variables
|
||||
if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '\\') && ! str_contains($shortName, '_')) {
|
||||
// This is camelCase - reject it as a class name
|
||||
// Only allow if it's a known valid exception (very rare)
|
||||
$validCamelCaseExceptions = [];
|
||||
if (! in_array($shortName, $validCamelCaseExceptions, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract namespace from file content
|
||||
*/
|
||||
private function extractFileNamespace(string $content): ?string
|
||||
{
|
||||
// Extract namespace from file using regex
|
||||
if (preg_match('/namespace\s+([^;]+);/', $content, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that extracted class belongs to the file's namespace
|
||||
* This prevents extracting classes that are only used (via use statements) but not declared
|
||||
*/
|
||||
private function belongsToFileNamespace(string $fqn, ?string $fileNamespace, ?string $extractedNamespace): bool
|
||||
{
|
||||
// If file has no namespace (global namespace), only classes without namespace should be extracted
|
||||
if ($fileNamespace === null || $fileNamespace === '') {
|
||||
// Check if FQN has no namespace (global namespace) - no backslash or only one at start
|
||||
return ! str_contains($fqn, '\\') || (str_starts_with($fqn, '\\') && substr_count($fqn, '\\') === 1);
|
||||
}
|
||||
|
||||
// Extract namespace from FQN
|
||||
$lastBackslash = strrpos($fqn, '\\');
|
||||
if ($lastBackslash === false) {
|
||||
// Class name without namespace - in a namespaced file, this shouldn't happen
|
||||
// But if it does, it's invalid (classes in namespaced files should have namespace)
|
||||
return false;
|
||||
}
|
||||
|
||||
$fqnNamespace = substr($fqn, 0, $lastBackslash);
|
||||
|
||||
// Primary validation: Use the extracted namespace from tokenizer if available
|
||||
// This is the most reliable source as it comes directly from the namespace declaration
|
||||
// CRITICAL: Must match EXACTLY, not just start with
|
||||
if ($extractedNamespace !== null && $extractedNamespace !== '') {
|
||||
// Exact match required - no sub-namespaces allowed
|
||||
if ($extractedNamespace !== $fileNamespace) {
|
||||
return false;
|
||||
}
|
||||
// Also validate that FQN namespace matches
|
||||
return $fqnNamespace === $fileNamespace;
|
||||
}
|
||||
|
||||
// Fallback: Check if FQN namespace matches file namespace EXACTLY
|
||||
// This ensures the full qualified name matches the file's namespace
|
||||
// No partial matches - must be identical
|
||||
return $fqnNamespace === $fileNamespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a class is actually declared in the file content
|
||||
* This prevents extracting classes that are only used (via use statements) but not declared
|
||||
*/
|
||||
private function isClassDeclaredInFile(string $content, string $className, string $type): bool
|
||||
{
|
||||
// Build pattern to match class/interface/trait/enum declarations
|
||||
$keyword = match($type) {
|
||||
'class' => 'class',
|
||||
'interface' => 'interface',
|
||||
'trait' => 'trait',
|
||||
'enum' => 'enum',
|
||||
default => 'class'
|
||||
};
|
||||
|
||||
// Match: optional modifiers (final, abstract, readonly) + keyword + whitespace + class name
|
||||
// Must be followed by whitespace, {, or extends/implements
|
||||
$pattern = '/\b(?:final\s+|abstract\s+|readonly\s+)*' . preg_quote($keyword, '/') . '\s+' . preg_quote($className, '/') . '(?:\s|{|extends|implements)/';
|
||||
|
||||
return preg_match($pattern, $content) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content contains actual PHP code
|
||||
*/
|
||||
|
||||
@@ -38,6 +38,7 @@ final readonly class FileStreamProcessor
|
||||
callable $fileProcessor
|
||||
): int {
|
||||
$totalFiles = 0;
|
||||
$failedFiles = [];
|
||||
|
||||
error_log("FileStreamProcessor: Processing directories: " . implode(', ', $directories));
|
||||
|
||||
@@ -45,6 +46,7 @@ final readonly class FileStreamProcessor
|
||||
$directoryPath = FilePath::create($directory);
|
||||
|
||||
foreach ($this->streamPhpFiles($directoryPath) as $file) {
|
||||
$fileContext = null;
|
||||
try {
|
||||
// Extract classes from file
|
||||
$classNames = $this->classExtractor->extractFromFile($file);
|
||||
@@ -67,11 +69,13 @@ final readonly class FileStreamProcessor
|
||||
$this->processingContext->maybeCollectGarbage($totalFiles);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Only log errors, not every processed file
|
||||
$this->logger?->warning(
|
||||
"Failed to process file {$file->getPath()->toString()}: {$e->getMessage()} in FileStreamProcessor",
|
||||
LogContext::withException($e)
|
||||
);
|
||||
// Collect error information for aggregated reporting
|
||||
$filePath = $file->getPath()->toString();
|
||||
$failedFiles[] = [
|
||||
'file' => $filePath,
|
||||
'error' => $e->getMessage(),
|
||||
'type' => $e::class,
|
||||
];
|
||||
} finally {
|
||||
// Always cleanup after processing a file
|
||||
$this->processingContext->cleanup();
|
||||
@@ -79,6 +83,28 @@ final readonly class FileStreamProcessor
|
||||
}
|
||||
}
|
||||
|
||||
// Log aggregated summary instead of individual errors
|
||||
if (!empty($failedFiles)) {
|
||||
$failedCount = count($failedFiles);
|
||||
$errorMessage = sprintf(
|
||||
'Failed to read %d file(s) because of syntax errors or other issues.',
|
||||
$failedCount
|
||||
);
|
||||
|
||||
$context = LogContext::create()->withData([
|
||||
'failed_files_count' => $failedCount,
|
||||
'total_files_processed' => $totalFiles,
|
||||
'failed_files' => array_map(fn($f) => [
|
||||
'file' => $f['file'],
|
||||
'error' => $f['error'],
|
||||
'type' => $f['type'],
|
||||
], $failedFiles)
|
||||
]);
|
||||
|
||||
$this->logger?->warning($errorMessage, $context);
|
||||
error_log("FileStreamProcessor: {$errorMessage}");
|
||||
}
|
||||
|
||||
error_log("FileStreamProcessor: Total files processed: " . $totalFiles);
|
||||
|
||||
return $totalFiles;
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Framework\Discovery\ValueObjects\TemplateMapping;
|
||||
use App\Framework\Filesystem\File;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Logging\ValueObjects\LogContext;
|
||||
|
||||
/**
|
||||
* Coordinates visitor execution with shared reflection context
|
||||
@@ -72,19 +73,41 @@ final class VisitorCoordinator
|
||||
FileContext $fileContext,
|
||||
DiscoveryDataCollector $collector
|
||||
): void {
|
||||
// Get shared reflection instance
|
||||
$reflection = $this->processingContext->getReflection($className);
|
||||
if ($reflection === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Get shared reflection instance
|
||||
$reflection = $this->processingContext->getReflection($className);
|
||||
if ($reflection === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process attributes
|
||||
$this->processClassAttributes($className, $fileContext, $reflection, $collector);
|
||||
$this->processMethodAttributes($className, $fileContext, $reflection, $collector);
|
||||
// Process attributes
|
||||
$this->processClassAttributes($className, $fileContext, $reflection, $collector);
|
||||
$this->processMethodAttributes($className, $fileContext, $reflection, $collector);
|
||||
|
||||
// Process interface implementations
|
||||
if (! empty($this->targetInterfaces)) {
|
||||
$this->processInterfaces($className, $reflection, $collector);
|
||||
// Process interface implementations
|
||||
if (! empty($this->targetInterfaces)) {
|
||||
$this->processInterfaces($className, $reflection, $collector);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errorMessage = sprintf(
|
||||
'Failed to process class "%s" in file %s: %s',
|
||||
$className->getFullyQualified(),
|
||||
$fileContext->path->toString(),
|
||||
$e->getMessage()
|
||||
);
|
||||
|
||||
$context = LogContext::withException($e)->withData([
|
||||
'class_name' => $className->getFullyQualified(),
|
||||
'file_path' => $fileContext->path->toString(),
|
||||
'exception_class' => $e::class,
|
||||
'exception_file' => $e->getFile(),
|
||||
'exception_line' => $e->getLine(),
|
||||
]);
|
||||
|
||||
$this->logger?->warning($errorMessage, $context);
|
||||
error_log($errorMessage);
|
||||
|
||||
// Don't re-throw - continue processing other classes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,29 +153,78 @@ final class VisitorCoordinator
|
||||
$reflection,
|
||||
DiscoveryDataCollector $collector
|
||||
): void {
|
||||
foreach ($reflection->getMethods() as $method) {
|
||||
foreach ($method->getAttributes() as $attribute) {
|
||||
$attributeClass = $attribute->getName();
|
||||
try {
|
||||
$methods = $reflection->getMethods();
|
||||
} catch (\Throwable $e) {
|
||||
$errorMessage = sprintf(
|
||||
'Failed to get methods for class "%s" in file %s: %s',
|
||||
$className->getFullyQualified(),
|
||||
$fileContext->path->toString(),
|
||||
$e->getMessage()
|
||||
);
|
||||
|
||||
$context = LogContext::withException($e)->withData([
|
||||
'class_name' => $className->getFullyQualified(),
|
||||
'file_path' => $fileContext->path->toString(),
|
||||
'exception_class' => $e::class,
|
||||
'exception_file' => $e->getFile(),
|
||||
'exception_line' => $e->getLine(),
|
||||
]);
|
||||
|
||||
$this->logger?->warning($errorMessage, $context);
|
||||
error_log($errorMessage);
|
||||
|
||||
// Return early if we can't get methods
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($methods as $method) {
|
||||
try {
|
||||
foreach ($method->getAttributes() as $attribute) {
|
||||
$attributeClass = $attribute->getName();
|
||||
|
||||
if ($this->shouldIgnoreAttribute($attributeClass)) {
|
||||
continue;
|
||||
if ($this->shouldIgnoreAttribute($attributeClass)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mappedData = $this->applyMapper($attributeClass, $method, $attribute);
|
||||
|
||||
$discovered = new DiscoveredAttribute(
|
||||
className: $className,
|
||||
attributeClass: $attributeClass,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create($method->getName()),
|
||||
propertyName: null,
|
||||
arguments: $this->extractAttributeArguments($attribute),
|
||||
filePath: $fileContext->path,
|
||||
additionalData: $mappedData ?? []
|
||||
);
|
||||
|
||||
$collector->getAttributeRegistry()->add($attributeClass, $discovered);
|
||||
}
|
||||
|
||||
|
||||
$mappedData = $this->applyMapper($attributeClass, $method, $attribute);
|
||||
|
||||
$discovered = new DiscoveredAttribute(
|
||||
className: $className,
|
||||
attributeClass: $attributeClass,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create($method->getName()),
|
||||
propertyName: null,
|
||||
arguments: $this->extractAttributeArguments($attribute),
|
||||
filePath: $fileContext->path,
|
||||
additionalData: $mappedData ?? []
|
||||
} catch (\Throwable $e) {
|
||||
$errorMessage = sprintf(
|
||||
'Failed to process method "%s" in class "%s" in file %s: %s',
|
||||
$method->getName(),
|
||||
$className->getFullyQualified(),
|
||||
$fileContext->path->toString(),
|
||||
$e->getMessage()
|
||||
);
|
||||
|
||||
$collector->getAttributeRegistry()->add($attributeClass, $discovered);
|
||||
|
||||
$context = LogContext::withException($e)->withData([
|
||||
'class_name' => $className->getFullyQualified(),
|
||||
'method_name' => $method->getName(),
|
||||
'file_path' => $fileContext->path->toString(),
|
||||
'exception_class' => $e::class,
|
||||
'exception_file' => $e->getFile(),
|
||||
'exception_line' => $e->getLine(),
|
||||
]);
|
||||
|
||||
$this->logger?->warning($errorMessage, $context);
|
||||
error_log($errorMessage);
|
||||
|
||||
// Continue with next method
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
164
src/Framework/Examples/MultiPurposeAction.php
Normal file
164
src/Framework/Examples/MultiPurposeAction.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Examples;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Mcp\McpTool;
|
||||
use App\Framework\Router\ActionResult;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
|
||||
/**
|
||||
* Example implementation of a multi-purpose action
|
||||
*
|
||||
* This class demonstrates how a single method can be used as:
|
||||
* - MCP Tool (for AI integration)
|
||||
* - Console Command (for CLI usage)
|
||||
* - HTTP Route (for API access)
|
||||
*
|
||||
* Requirements:
|
||||
* - Only builtin parameter types (string, int, bool, float, array)
|
||||
* - Returns ActionResult (unified return type)
|
||||
*/
|
||||
final readonly class MultiPurposeAction
|
||||
{
|
||||
/**
|
||||
* List users with filtering and pagination
|
||||
*
|
||||
* This method can be called via:
|
||||
* - MCP: {"name": "list_users", "arguments": {"status": "active", "limit": 10}}
|
||||
* - Console: users:list --status=active --limit=10
|
||||
* - HTTP: GET /api/users?status=active&limit=10
|
||||
*/
|
||||
#[McpTool(
|
||||
name: 'list_users',
|
||||
description: 'List users with optional filtering and pagination'
|
||||
)]
|
||||
#[ConsoleCommand(
|
||||
name: 'users:list',
|
||||
description: 'List users with optional filtering and pagination'
|
||||
)]
|
||||
#[Route(
|
||||
path: '/api/users',
|
||||
method: Method::GET
|
||||
)]
|
||||
public function listUsers(
|
||||
string $status = 'active',
|
||||
int $limit = 10,
|
||||
bool $includeDetails = false
|
||||
): ActionResult {
|
||||
// Simulate user data
|
||||
$users = [
|
||||
['id' => 1, 'name' => 'John Doe', 'status' => 'active'],
|
||||
['id' => 2, 'name' => 'Jane Smith', 'status' => 'active'],
|
||||
['id' => 3, 'name' => 'Bob Johnson', 'status' => 'inactive'],
|
||||
];
|
||||
|
||||
// Filter by status
|
||||
$filteredUsers = array_filter($users, fn($user) => $user['status'] === $status);
|
||||
|
||||
// Limit results
|
||||
$limitedUsers = array_slice($filteredUsers, 0, $limit);
|
||||
|
||||
// Add details if requested
|
||||
if ($includeDetails) {
|
||||
foreach ($limitedUsers as &$user) {
|
||||
$user['email'] = strtolower(str_replace(' ', '.', $user['name'])) . '@example.com';
|
||||
$user['created_at'] = '2024-01-01';
|
||||
}
|
||||
}
|
||||
|
||||
// Return unified ActionResult
|
||||
return new JsonResult([
|
||||
'users' => array_values($limitedUsers),
|
||||
'total' => count($limitedUsers),
|
||||
'status' => $status,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*
|
||||
* This method can be called via:
|
||||
* - MCP: {"name": "get_user", "arguments": {"userId": "1"}}
|
||||
* - Console: users:get --userId=1
|
||||
* - HTTP: GET /api/users/1
|
||||
*/
|
||||
#[McpTool(
|
||||
name: 'get_user',
|
||||
description: 'Get a specific user by ID'
|
||||
)]
|
||||
#[ConsoleCommand(
|
||||
name: 'users:get',
|
||||
description: 'Get a specific user by ID'
|
||||
)]
|
||||
#[Route(
|
||||
path: '/api/users/{userId}',
|
||||
method: Method::GET
|
||||
)]
|
||||
public function getUser(int $userId): ActionResult
|
||||
{
|
||||
// Simulate user data
|
||||
$users = [
|
||||
1 => ['id' => 1, 'name' => 'John Doe', 'status' => 'active', 'email' => 'john.doe@example.com'],
|
||||
2 => ['id' => 2, 'name' => 'Jane Smith', 'status' => 'active', 'email' => 'jane.smith@example.com'],
|
||||
3 => ['id' => 3, 'name' => 'Bob Johnson', 'status' => 'inactive', 'email' => 'bob.johnson@example.com'],
|
||||
];
|
||||
|
||||
if (! isset($users[$userId])) {
|
||||
return new JsonResult([
|
||||
'error' => 'User not found',
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
return new JsonResult([
|
||||
'user' => $users[$userId],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*
|
||||
* This method can be called via:
|
||||
* - MCP: {"name": "create_user", "arguments": {"name": "Alice", "email": "alice@example.com"}}
|
||||
* - Console: users:create --name=Alice --email=alice@example.com
|
||||
* - HTTP: POST /api/users with JSON body
|
||||
*/
|
||||
#[McpTool(
|
||||
name: 'create_user',
|
||||
description: 'Create a new user'
|
||||
)]
|
||||
#[ConsoleCommand(
|
||||
name: 'users:create',
|
||||
description: 'Create a new user'
|
||||
)]
|
||||
#[Route(
|
||||
path: '/api/users',
|
||||
method: Method::POST
|
||||
)]
|
||||
public function createUser(
|
||||
string $name,
|
||||
string $email,
|
||||
bool $active = true
|
||||
): ActionResult {
|
||||
// Simulate user creation
|
||||
$newUser = [
|
||||
'id' => 4,
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'status' => $active ? 'active' : 'inactive',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
return new JsonResult([
|
||||
'user' => $newUser,
|
||||
'message' => 'User created successfully',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ final readonly class WafMiddleware implements HttpMiddleware
|
||||
|
||||
// Debug log analysis result
|
||||
$this->logger->debug('WAF analysis complete', LogContext::withData([
|
||||
'result_status' => $wafResult->getStatus()->value ?? 'unknown',
|
||||
'result_status' => $wafResult->status->value ?? 'unknown',
|
||||
'result_action' => $wafResult->getAction(),
|
||||
'layer_name' => $wafResult->getLayerName(),
|
||||
'message' => $wafResult->getMessage(),
|
||||
|
||||
@@ -58,6 +58,148 @@ final readonly class CurlHttpClient implements HttpClient
|
||||
// Wrap any exception in CurlExecutionFailed for backward compatibility
|
||||
throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request with streaming response to destination
|
||||
*
|
||||
* Streams HTTP response directly to a writable stream resource.
|
||||
* Useful for large file downloads without loading entire response into memory.
|
||||
*
|
||||
* @param ClientRequest $request HTTP request to send
|
||||
* @param resource $destination Writable stream resource (e.g., fopen('file.txt', 'w'))
|
||||
* @return StreamingResponse Response with headers and status, but no body (streamed to destination)
|
||||
* @throws CurlExecutionFailed If request execution fails
|
||||
*/
|
||||
public function sendStreaming(ClientRequest $request, $destination): StreamingResponse
|
||||
{
|
||||
if (! is_resource($destination)) {
|
||||
throw new \InvalidArgumentException('Destination must be a valid stream resource');
|
||||
}
|
||||
|
||||
$handle = new Handle();
|
||||
|
||||
try {
|
||||
// Build options using HandleOption enum
|
||||
$options = $this->requestBuilder->buildOptions($request);
|
||||
|
||||
// Remove CURLOPT_RETURNTRANSFER (we're streaming to destination)
|
||||
unset($options[\App\Framework\HttpClient\Curl\HandleOption::ReturnTransfer->value]);
|
||||
|
||||
// Handle authentication
|
||||
if ($request->options->auth !== null) {
|
||||
$authResult = $this->authenticationHandler->configure($request->options->auth, $request->headers);
|
||||
|
||||
if ($authResult->headers !== $request->headers) {
|
||||
$updatedRequest = $request->with(['headers' => $authResult->headers]);
|
||||
$options = $this->requestBuilder->buildOptions($updatedRequest);
|
||||
unset($options[\App\Framework\HttpClient\Curl\HandleOption::ReturnTransfer->value]);
|
||||
}
|
||||
|
||||
if (! empty($authResult->curlOptions)) {
|
||||
$options = array_replace($options, $authResult->curlOptions);
|
||||
}
|
||||
}
|
||||
|
||||
// Enable header capture for streaming
|
||||
$headerBuffer = '';
|
||||
$headerFunction = function ($ch, $header) use (&$headerBuffer) {
|
||||
$headerBuffer .= $header;
|
||||
return strlen($header);
|
||||
};
|
||||
$options[\App\Framework\HttpClient\Curl\HandleOption::HeaderFunction->value] = $headerFunction;
|
||||
|
||||
// Set all options
|
||||
$handle->setOptions($options);
|
||||
|
||||
// Execute and stream directly to destination
|
||||
$handle->execute($destination);
|
||||
|
||||
// Parse response headers from buffer
|
||||
$statusCode = $handle->getInfo(\App\Framework\HttpClient\Curl\Info::ResponseCode);
|
||||
$status = \App\Framework\Http\Status::from((int) $statusCode);
|
||||
$headers = $this->responseParser->parseHeaders($headerBuffer);
|
||||
|
||||
// Get bytes written (if available)
|
||||
$bytesWritten = (int) ($handle->getInfo(\App\Framework\HttpClient\Curl\Info::SizeDownload) ?: 0);
|
||||
|
||||
return new StreamingResponse(
|
||||
status: $status,
|
||||
headers: $headers,
|
||||
bytesWritten: $bytesWritten
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request with streaming body from source
|
||||
*
|
||||
* Streams HTTP request body from a readable stream resource.
|
||||
* Useful for large file uploads without loading entire file into memory.
|
||||
*
|
||||
* @param ClientRequest $request HTTP request to send (body will be replaced by stream)
|
||||
* @param resource $source Readable stream resource (e.g., fopen('file.txt', 'r'))
|
||||
* @param int|null $contentLength Content-Length in bytes (null for chunked transfer)
|
||||
* @return ClientResponse HTTP response
|
||||
* @throws CurlExecutionFailed If request execution fails
|
||||
*/
|
||||
public function sendStreamingUpload(ClientRequest $request, $source, ?int $contentLength = null): ClientResponse
|
||||
{
|
||||
if (! is_resource($source)) {
|
||||
throw new \InvalidArgumentException('Source must be a valid stream resource');
|
||||
}
|
||||
|
||||
$handle = new Handle();
|
||||
|
||||
try {
|
||||
// Build options using HandleOption enum
|
||||
$options = $this->requestBuilder->buildOptions($request);
|
||||
|
||||
// Remove CURLOPT_POSTFIELDS (we're using stream)
|
||||
unset($options[\App\Framework\HttpClient\Curl\HandleOption::PostFields->value]);
|
||||
|
||||
// Set streaming upload options
|
||||
$options[\App\Framework\HttpClient\Curl\HandleOption::Upload->value] = true;
|
||||
$options[\App\Framework\HttpClient\Curl\HandleOption::InFile->value] = $source;
|
||||
|
||||
if ($contentLength !== null) {
|
||||
$options[\App\Framework\HttpClient\Curl\HandleOption::InFileSize->value] = $contentLength;
|
||||
}
|
||||
|
||||
// Handle authentication
|
||||
if ($request->options->auth !== null) {
|
||||
$authResult = $this->authenticationHandler->configure($request->options->auth, $request->headers);
|
||||
|
||||
if ($authResult->headers !== $request->headers) {
|
||||
$updatedRequest = $request->with(['headers' => $authResult->headers]);
|
||||
$options = array_replace($options, $this->requestBuilder->buildOptions($updatedRequest));
|
||||
unset($options[\App\Framework\HttpClient\Curl\HandleOption::PostFields->value]);
|
||||
$options[\App\Framework\HttpClient\Curl\HandleOption::Upload->value] = true;
|
||||
$options[\App\Framework\HttpClient\Curl\HandleOption::InFile->value] = $source;
|
||||
if ($contentLength !== null) {
|
||||
$options[\App\Framework\HttpClient\Curl\HandleOption::InFileSize->value] = $contentLength;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($authResult->curlOptions)) {
|
||||
$options = array_replace($options, $authResult->curlOptions);
|
||||
}
|
||||
}
|
||||
|
||||
// Set all options
|
||||
$handle->setOptions($options);
|
||||
|
||||
// Execute request (automatically uses CURLOPT_RETURNTRANSFER)
|
||||
$rawResponse = $handle->fetch();
|
||||
|
||||
// Parse response
|
||||
return $this->responseParser->parse($rawResponse, $handle->getResource());
|
||||
} catch (\Throwable $e) {
|
||||
throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -127,6 +269,7 @@ final readonly class CurlHttpClient implements HttpClient
|
||||
status: Status::from($status),
|
||||
headers: $headers,
|
||||
body: $body
|
||||
);*/
|
||||
);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ final readonly class CurlResponseParser
|
||||
return new ClientResponse($status, $headers, $body);
|
||||
}
|
||||
|
||||
private function parseHeaders(string $headersRaw): Headers
|
||||
public function parseHeaders(string $headersRaw): Headers
|
||||
{
|
||||
$headers = new Headers();
|
||||
$lines = explode("\r\n", trim($headersRaw));
|
||||
|
||||
30
src/Framework/HttpClient/StreamingResponse.php
Normal file
30
src/Framework/HttpClient/StreamingResponse.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\HttpClient;
|
||||
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\Status;
|
||||
|
||||
/**
|
||||
* Response from streaming HTTP request
|
||||
*
|
||||
* Contains status and headers, but no body (body was streamed to destination).
|
||||
* Includes bytesWritten count for verification.
|
||||
*/
|
||||
final readonly class StreamingResponse
|
||||
{
|
||||
public function __construct(
|
||||
public Status $status,
|
||||
public Headers $headers,
|
||||
public int $bytesWritten = 0
|
||||
) {
|
||||
}
|
||||
|
||||
public function isSuccessful(): bool
|
||||
{
|
||||
return $this->status->isSuccess();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,29 +4,36 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Logging\Handlers;
|
||||
|
||||
use App\Framework\Logging\LogHandler;
|
||||
use App\Framework\Logging\LogLevel;
|
||||
use App\Framework\Logging\LogRecord;
|
||||
use App\Framework\Logging\ValueObjects\LogContext;
|
||||
use App\Framework\Logging\ValueObjects\LogEntry;
|
||||
|
||||
/**
|
||||
* In-memory log handler for testing
|
||||
*
|
||||
* Stores all log entries in memory for inspection and assertions in tests
|
||||
* Stores all log records in memory for inspection and assertions in tests
|
||||
*/
|
||||
final class InMemoryHandler implements LogHandler
|
||||
{
|
||||
/** @var LogEntry[] */
|
||||
/** @var LogRecord[] */
|
||||
private array $entries = [];
|
||||
|
||||
public function handle(LogEntry $entry): void
|
||||
public function isHandling(LogRecord $record): bool
|
||||
{
|
||||
$this->entries[] = $entry;
|
||||
// In-memory handler captures all log records for testing
|
||||
return true;
|
||||
}
|
||||
|
||||
public function handle(LogRecord $record): void
|
||||
{
|
||||
$this->entries[] = $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all logged entries
|
||||
*
|
||||
* @return LogEntry[]
|
||||
* @return LogRecord[]
|
||||
*/
|
||||
public function getEntries(): array
|
||||
{
|
||||
@@ -36,13 +43,13 @@ final class InMemoryHandler implements LogHandler
|
||||
/**
|
||||
* Get entries by log level
|
||||
*
|
||||
* @return LogEntry[]
|
||||
* @return LogRecord[]
|
||||
*/
|
||||
public function getEntriesByLevel(LogLevel $level): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->entries,
|
||||
fn(LogEntry $entry) => $entry->level === $level
|
||||
fn(LogRecord $entry) => $entry->level === $level
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ final class PerformanceProcessor implements LogProcessor
|
||||
}
|
||||
}
|
||||
|
||||
public function process(LogRecord $record): LogRecord
|
||||
public function processRecord(LogRecord $record): LogRecord
|
||||
{
|
||||
$performance = [];
|
||||
|
||||
@@ -54,3 +54,22 @@ final class PerformanceProcessor implements LogProcessor
|
||||
$elapsed = Timestamp::now()->diffInMilliseconds(self::$requestStartTime);
|
||||
$performance['execution_time_ms'] = round($elapsed, 2);
|
||||
}
|
||||
|
||||
// Merge performance data into log record context
|
||||
return $record->withContext(array_merge(
|
||||
$record->context,
|
||||
['performance' => $performance]
|
||||
));
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
// Low priority - add performance data at the end
|
||||
return 10;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'performance';
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mcp\Core\ValueObjects;
|
||||
|
||||
use App\Framework\Router\ActionResult;
|
||||
|
||||
/**
|
||||
* Value Object für MCP Tool Ergebnisse
|
||||
*
|
||||
* Standardisiert die Rückgabe aller MCP Tools
|
||||
*/
|
||||
final readonly class ToolResult
|
||||
final readonly class ToolResult implements ActionResult
|
||||
{
|
||||
public function __construct(
|
||||
public mixed $data,
|
||||
|
||||
@@ -110,13 +110,24 @@ final readonly class McpServer
|
||||
|
||||
$result = $instance->$method(...$this->prepareArguments($tool['parameters'], $arguments));
|
||||
|
||||
// Handle ActionResult - convert to ToolResult if needed
|
||||
if ($result instanceof \App\Framework\Router\ActionResult) {
|
||||
$toolResult = $this->convertActionResultToToolResult($result);
|
||||
} elseif ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) {
|
||||
$toolResult = $result;
|
||||
} else {
|
||||
// Convert plain result to ToolResult
|
||||
$toolResult = \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result);
|
||||
}
|
||||
|
||||
// Convert ToolResult to MCP response
|
||||
$response = [
|
||||
'jsonrpc' => '2.0',
|
||||
'result' => [
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'text',
|
||||
'text' => is_string($result) ? $result : json_encode($result, JSON_PRETTY_PRINT),
|
||||
'text' => json_encode($toolResult->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -130,6 +141,35 @@ final readonly class McpServer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ActionResult to ToolResult
|
||||
*/
|
||||
private function convertActionResultToToolResult(\App\Framework\Router\ActionResult $result): \App\Framework\Mcp\Core\ValueObjects\ToolResult
|
||||
{
|
||||
// If it's already a ToolResult, return it
|
||||
if ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Convert JsonResult to ToolResult
|
||||
if ($result instanceof \App\Framework\Router\Result\JsonResult) {
|
||||
return \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result->data);
|
||||
}
|
||||
|
||||
// Convert ConsoleResult to ToolResult
|
||||
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
|
||||
$success = $result->exitCode->value === 0;
|
||||
$message = $result->data['message'] ?? ($success ? 'Command executed successfully' : 'Command failed');
|
||||
|
||||
return $success
|
||||
? \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result->data, ['message' => $message])
|
||||
: \App\Framework\Mcp\Core\ValueObjects\ToolResult::failure($message, $result->data);
|
||||
}
|
||||
|
||||
// Default: convert to success ToolResult
|
||||
return \App\Framework\Mcp\Core\ValueObjects\ToolResult::success(['result' => 'Operation completed']);
|
||||
}
|
||||
|
||||
private function listResources($requestId = null): string
|
||||
{
|
||||
$resources = [];
|
||||
|
||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mcp;
|
||||
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Core\AttributeMapper;
|
||||
use App\Framework\Core\ParameterTypeValidator;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Mcp\Core\ValueObjects\OutputFormat;
|
||||
use App\Framework\Reflection\WrappedReflectionClass;
|
||||
use App\Framework\Reflection\WrappedReflectionMethod;
|
||||
@@ -15,6 +18,13 @@ use ReflectionUnionType;
|
||||
|
||||
final readonly class McpToolMapper implements AttributeMapper
|
||||
{
|
||||
private ParameterTypeValidator $typeValidator;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->typeValidator = new ParameterTypeValidator();
|
||||
}
|
||||
|
||||
public function getAttributeClass(): string
|
||||
{
|
||||
return McpTool::class;
|
||||
@@ -28,6 +38,26 @@ final readonly class McpToolMapper implements AttributeMapper
|
||||
|
||||
$class = $reflectionTarget->getDeclaringClass();
|
||||
|
||||
// Check if method has multiple attributes (multi-purpose)
|
||||
$hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget);
|
||||
|
||||
// If multi-purpose, validate that all parameters are builtin types
|
||||
if ($hasMultipleAttributes) {
|
||||
$parameters = $reflectionTarget->getParameters()->toArray();
|
||||
$reflectionParameters = [];
|
||||
foreach ($parameters as $param) {
|
||||
$reflectionParameters[] = $param->getType();
|
||||
}
|
||||
|
||||
if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) {
|
||||
// Skip this attribute if parameters are not all builtin
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get other attributes for metadata
|
||||
$otherAttributes = $this->getOtherPurposeAttributes($reflectionTarget);
|
||||
|
||||
return [
|
||||
'name' => $attributeInstance->name,
|
||||
'description' => $attributeInstance->description,
|
||||
@@ -35,9 +65,56 @@ final readonly class McpToolMapper implements AttributeMapper
|
||||
'class' => $class->getFullyQualified(),
|
||||
'method' => $reflectionTarget->getName(),
|
||||
'parameters' => $this->extractParameters($reflectionTarget),
|
||||
'multi_purpose' => $hasMultipleAttributes,
|
||||
'other_attributes' => $otherAttributes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route)
|
||||
*/
|
||||
private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool
|
||||
{
|
||||
$attributes = $method->getAttributes();
|
||||
$purposeAttributeCount = 0;
|
||||
|
||||
foreach ($attributes as $attribute) {
|
||||
$attributeName = $attribute->getName();
|
||||
if (in_array($attributeName, [
|
||||
McpTool::class,
|
||||
ConsoleCommand::class,
|
||||
Route::class,
|
||||
], true)) {
|
||||
$purposeAttributeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $purposeAttributeCount > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get other purpose attributes on the same method
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
private function getOtherPurposeAttributes(WrappedReflectionMethod $method): array
|
||||
{
|
||||
$attributes = $method->getAttributes();
|
||||
$otherAttributes = [];
|
||||
|
||||
foreach ($attributes as $attribute) {
|
||||
$attributeName = $attribute->getName();
|
||||
if (in_array($attributeName, [
|
||||
ConsoleCommand::class,
|
||||
Route::class,
|
||||
], true)) {
|
||||
$otherAttributes[] = $attributeName;
|
||||
}
|
||||
}
|
||||
|
||||
return $otherAttributes;
|
||||
}
|
||||
|
||||
private function generateInputSchema(WrappedReflectionMethod $method, McpTool $tool): array
|
||||
{
|
||||
$schema = [
|
||||
@@ -262,7 +339,7 @@ final readonly class McpToolMapper implements AttributeMapper
|
||||
$type = $param->getType();
|
||||
$parameters[] = [
|
||||
'name' => $param->getName(),
|
||||
'type' => $type ? $type->getName() : 'mixed',
|
||||
'type' => $type ? ($type instanceof \ReflectionNamedType ? $type->getName() : 'mixed') : 'mixed',
|
||||
'required' => ! $param->isOptional(),
|
||||
'default' => $param->isOptional() ? $param->getDefaultValue() : null,
|
||||
];
|
||||
|
||||
@@ -4,26 +4,29 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mcp\Tools;
|
||||
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
use App\Framework\HttpClient\HttpMethod;
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Mcp\McpTool;
|
||||
use App\Framework\Router\GenericResult;
|
||||
use App\Infrastructure\Api\Gitea\GiteaClient;
|
||||
|
||||
/**
|
||||
* Gitea Repository Management MCP Tools
|
||||
*
|
||||
* Provides AI-accessible Gitea API operations for repository management,
|
||||
* SSH key setup, and deployment automation.
|
||||
* issue tracking, CI/CD workflows, and deployment automation.
|
||||
*
|
||||
* Architecture: Leverages Infrastructure/Api/Gitea service classes for
|
||||
* clean separation and better maintainability.
|
||||
*/
|
||||
final readonly class GiteaTools
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClient $httpClient,
|
||||
private string $giteaUrl,
|
||||
private string $giteaUsername,
|
||||
private string $giteaPassword
|
||||
private GiteaClient $giteaClient
|
||||
) {
|
||||
}
|
||||
|
||||
// ==================== Repository Management ====================
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_create_repository',
|
||||
description: 'Create a new repository in Gitea'
|
||||
@@ -35,67 +38,51 @@ final readonly class GiteaTools
|
||||
bool $autoInit = false,
|
||||
string $defaultBranch = 'main'
|
||||
): array {
|
||||
$url = "{$this->giteaUrl}/api/v1/user/repos";
|
||||
try {
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'private' => $private,
|
||||
'auto_init' => $autoInit,
|
||||
'default_branch' => $defaultBranch,
|
||||
];
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'private' => $private,
|
||||
'auto_init' => $autoInit,
|
||||
'default_branch' => $defaultBranch,
|
||||
];
|
||||
$repository = $this->giteaClient->repositories->create($data);
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
|
||||
|
||||
if ($result['success']) {
|
||||
return [
|
||||
'success' => true,
|
||||
'repository' => [
|
||||
'name' => $result['response']['name'] ?? $name,
|
||||
'full_name' => $result['response']['full_name'] ?? "{$this->giteaUsername}/$name",
|
||||
'clone_url' => $result['response']['clone_url'] ?? null,
|
||||
'ssh_url' => $result['response']['ssh_url'] ?? null,
|
||||
'html_url' => $result['response']['html_url'] ?? null,
|
||||
'private' => $result['response']['private'] ?? $private,
|
||||
'id' => $result['response']['id'] ?? null,
|
||||
],
|
||||
'repository' => $this->formatRepository($repository),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, 'Failed to create repository');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_list_repositories',
|
||||
description: 'List all repositories for the authenticated user'
|
||||
)]
|
||||
public function listRepositories(): array
|
||||
public function listRepositories(int $page = 1, int $limit = 30): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/user/repos";
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::GET, $url);
|
||||
|
||||
if ($result['success']) {
|
||||
$repos = array_map(function ($repo) {
|
||||
return [
|
||||
'name' => $repo['name'] ?? 'unknown',
|
||||
'full_name' => $repo['full_name'] ?? 'unknown',
|
||||
'description' => $repo['description'] ?? '',
|
||||
'private' => $repo['private'] ?? false,
|
||||
'clone_url' => $repo['clone_url'] ?? null,
|
||||
'ssh_url' => $repo['ssh_url'] ?? null,
|
||||
'html_url' => $repo['html_url'] ?? null,
|
||||
];
|
||||
}, $result['response'] ?? []);
|
||||
try {
|
||||
$repositories = $this->giteaClient->repositories->list([
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'repositories' => $repos,
|
||||
'count' => count($repos),
|
||||
'repositories' => array_map(
|
||||
fn($repo) => $this->formatRepository($repo),
|
||||
$repositories
|
||||
),
|
||||
'count' => count($repositories),
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, 'Failed to list repositories');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
@@ -104,198 +91,374 @@ final readonly class GiteaTools
|
||||
)]
|
||||
public function getRepository(string $owner, string $repo): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo";
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::GET, $url);
|
||||
|
||||
if ($result['success']) {
|
||||
$repo = $result['response'];
|
||||
try {
|
||||
$repository = $this->giteaClient->repositories->get($owner, $repo);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'repository' => [
|
||||
'name' => $repo['name'] ?? 'unknown',
|
||||
'full_name' => $repo['full_name'] ?? 'unknown',
|
||||
'description' => $repo['description'] ?? '',
|
||||
'private' => $repo['private'] ?? false,
|
||||
'clone_url' => $repo['clone_url'] ?? null,
|
||||
'ssh_url' => $repo['ssh_url'] ?? null,
|
||||
'html_url' => $repo['html_url'] ?? null,
|
||||
'default_branch' => $repo['default_branch'] ?? 'main',
|
||||
'created_at' => $repo['created_at'] ?? null,
|
||||
'updated_at' => $repo['updated_at'] ?? null,
|
||||
],
|
||||
'repository' => $this->formatRepository($repository),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to get repository {$owner}/{$repo}");
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
#[McpTool(
|
||||
name: 'gitea_update_repository',
|
||||
description: 'Update repository settings'
|
||||
)]
|
||||
public function updateRepository(
|
||||
string $owner,
|
||||
string $repo,
|
||||
?string $description = null,
|
||||
?bool $private = null,
|
||||
?string $website = null,
|
||||
?bool $hasIssues = null,
|
||||
?bool $hasWiki = null,
|
||||
?string $defaultBranch = null
|
||||
): array {
|
||||
try {
|
||||
$data = array_filter([
|
||||
'description' => $description,
|
||||
'private' => $private,
|
||||
'website' => $website,
|
||||
'has_issues' => $hasIssues,
|
||||
'has_wiki' => $hasWiki,
|
||||
'default_branch' => $defaultBranch,
|
||||
], fn($value) => $value !== null);
|
||||
|
||||
$repository = $this->giteaClient->repositories->update($owner, $repo, $data);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'repository' => $this->formatRepository($repository),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to update repository {$owner}/{$repo}");
|
||||
}
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_delete_repository',
|
||||
description: 'Delete a repository'
|
||||
description: 'Delete a repository (DANGEROUS - permanent deletion)'
|
||||
)]
|
||||
public function deleteRepository(string $owner, string $repo): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo";
|
||||
try {
|
||||
$this->giteaClient->repositories->delete($owner, $repo);
|
||||
|
||||
return $this->makeRequest(HttpMethod::DELETE, $url);
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "Repository {$owner}/{$repo} deleted successfully",
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to delete repository {$owner}/{$repo}");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Issue Management ====================
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_add_deploy_key',
|
||||
description: 'Add an SSH deploy key to a repository'
|
||||
name: 'gitea_create_issue',
|
||||
description: 'Create a new issue in a repository'
|
||||
)]
|
||||
public function addDeployKey(
|
||||
public function createIssue(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $key,
|
||||
bool $readOnly = true
|
||||
string $body = '',
|
||||
?array $labels = null,
|
||||
?array $assignees = null,
|
||||
?int $milestone = null
|
||||
): array {
|
||||
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys";
|
||||
try {
|
||||
$data = array_filter([
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'labels' => $labels,
|
||||
'assignees' => $assignees,
|
||||
'milestone' => $milestone,
|
||||
], fn($value) => $value !== null);
|
||||
|
||||
$data = [
|
||||
'title' => $title,
|
||||
'key' => $key,
|
||||
'read_only' => $readOnly,
|
||||
];
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
|
||||
|
||||
if ($result['success']) {
|
||||
return [
|
||||
'success' => true,
|
||||
'deploy_key' => [
|
||||
'id' => $result['response']['id'] ?? null,
|
||||
'title' => $result['response']['title'] ?? $title,
|
||||
'key' => $result['response']['key'] ?? $key,
|
||||
'read_only' => $result['response']['read_only'] ?? $readOnly,
|
||||
'created_at' => $result['response']['created_at'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_list_deploy_keys',
|
||||
description: 'List all deploy keys for a repository'
|
||||
)]
|
||||
public function listDeployKeys(string $owner, string $repo): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys";
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::GET, $url);
|
||||
|
||||
if ($result['success']) {
|
||||
$keys = array_map(function ($key) {
|
||||
return [
|
||||
'id' => $key['id'] ?? null,
|
||||
'title' => $key['title'] ?? 'unknown',
|
||||
'key' => $key['key'] ?? '',
|
||||
'read_only' => $key['read_only'] ?? true,
|
||||
'created_at' => $key['created_at'] ?? null,
|
||||
];
|
||||
}, $result['response'] ?? []);
|
||||
$issue = $this->giteaClient->issues->create($owner, $repo, $data);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'deploy_keys' => $keys,
|
||||
'count' => count($keys),
|
||||
'issue' => $this->formatIssue($issue),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to create issue in {$owner}/{$repo}");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_delete_deploy_key',
|
||||
description: 'Delete a deploy key from a repository'
|
||||
name: 'gitea_list_issues',
|
||||
description: 'List issues in a repository'
|
||||
)]
|
||||
public function deleteDeployKey(string $owner, string $repo, int $keyId): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys/$keyId";
|
||||
public function listIssues(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $state = 'open',
|
||||
?array $labels = null,
|
||||
int $page = 1,
|
||||
int $limit = 30
|
||||
): array {
|
||||
try {
|
||||
$options = array_filter([
|
||||
'state' => $state,
|
||||
'labels' => $labels ? implode(',', $labels) : null,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
], fn($value) => $value !== null);
|
||||
|
||||
return $this->makeRequest(HttpMethod::DELETE, $url);
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_add_user_ssh_key',
|
||||
description: 'Add an SSH key to the authenticated user'
|
||||
)]
|
||||
public function addUserSshKey(string $title, string $key, bool $readOnly = false): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/user/keys";
|
||||
|
||||
$data = [
|
||||
'title' => $title,
|
||||
'key' => $key,
|
||||
'read_only' => $readOnly,
|
||||
];
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
|
||||
|
||||
if ($result['success']) {
|
||||
return [
|
||||
'success' => true,
|
||||
'ssh_key' => [
|
||||
'id' => $result['response']['id'] ?? null,
|
||||
'title' => $result['response']['title'] ?? $title,
|
||||
'key' => $result['response']['key'] ?? $key,
|
||||
'read_only' => $result['response']['read_only'] ?? $readOnly,
|
||||
'created_at' => $result['response']['created_at'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_list_user_ssh_keys',
|
||||
description: 'List all SSH keys for the authenticated user'
|
||||
)]
|
||||
public function listUserSshKeys(): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/user/keys";
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::GET, $url);
|
||||
|
||||
if ($result['success']) {
|
||||
$keys = array_map(function ($key) {
|
||||
return [
|
||||
'id' => $key['id'] ?? null,
|
||||
'title' => $key['title'] ?? 'unknown',
|
||||
'key' => $key['key'] ?? '',
|
||||
'fingerprint' => $key['fingerprint'] ?? '',
|
||||
'read_only' => $key['read_only'] ?? false,
|
||||
'created_at' => $key['created_at'] ?? null,
|
||||
];
|
||||
}, $result['response'] ?? []);
|
||||
$issues = $this->giteaClient->issues->list($owner, $repo, $options);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'ssh_keys' => $keys,
|
||||
'count' => count($keys),
|
||||
'issues' => array_map(
|
||||
fn($issue) => $this->formatIssue($issue),
|
||||
$issues
|
||||
),
|
||||
'count' => count($issues),
|
||||
'state' => $state,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to list issues in {$owner}/{$repo}");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_delete_user_ssh_key',
|
||||
description: 'Delete an SSH key from the authenticated user'
|
||||
name: 'gitea_get_issue',
|
||||
description: 'Get details of a specific issue'
|
||||
)]
|
||||
public function deleteUserSshKey(int $keyId): array
|
||||
public function getIssue(string $owner, string $repo, int $index): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/user/keys/$keyId";
|
||||
try {
|
||||
$issue = $this->giteaClient->issues->get($owner, $repo, $index);
|
||||
|
||||
return $this->makeRequest(HttpMethod::DELETE, $url);
|
||||
return [
|
||||
'success' => true,
|
||||
'issue' => $this->formatIssue($issue),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to get issue #{$index} in {$owner}/{$repo}");
|
||||
}
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_update_issue',
|
||||
description: 'Update an existing issue'
|
||||
)]
|
||||
public function updateIssue(
|
||||
string $owner,
|
||||
string $repo,
|
||||
int $index,
|
||||
?string $title = null,
|
||||
?string $body = null,
|
||||
?string $state = null,
|
||||
?array $labels = null,
|
||||
?array $assignees = null
|
||||
): array {
|
||||
try {
|
||||
$data = array_filter([
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'state' => $state,
|
||||
'labels' => $labels,
|
||||
'assignees' => $assignees,
|
||||
], fn($value) => $value !== null);
|
||||
|
||||
$issue = $this->giteaClient->issues->update($owner, $repo, $index, $data);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'issue' => $this->formatIssue($issue),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to update issue #{$index} in {$owner}/{$repo}");
|
||||
}
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_close_issue',
|
||||
description: 'Close an issue'
|
||||
)]
|
||||
public function closeIssue(string $owner, string $repo, int $index): array
|
||||
{
|
||||
try {
|
||||
$issue = $this->giteaClient->issues->close($owner, $repo, $index);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'issue' => $this->formatIssue($issue),
|
||||
'message' => "Issue #{$index} closed successfully",
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to close issue #{$index} in {$owner}/{$repo}");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== CI/CD & Actions ====================
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_list_workflows',
|
||||
description: 'List all workflows in a repository'
|
||||
)]
|
||||
#[ConsoleCommand(
|
||||
name: 'gitea:workflows:list',
|
||||
description: 'List all workflows in a Gitea repository'
|
||||
)]
|
||||
public function listWorkflows(string $owner, string $repo): GenericResult
|
||||
{
|
||||
try {
|
||||
$workflows = $this->giteaClient->actions->listWorkflows($owner, $repo);
|
||||
|
||||
return GenericResult::success([
|
||||
'workflows' => $workflows['workflows'] ?? [],
|
||||
'count' => count($workflows['workflows'] ?? []),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return GenericResult::fromException($e);
|
||||
}
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_trigger_workflow',
|
||||
description: 'Manually trigger a workflow'
|
||||
)]
|
||||
public function triggerWorkflow(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $workflowId,
|
||||
?string $ref = null,
|
||||
?array $inputs = null
|
||||
): array {
|
||||
try {
|
||||
$this->giteaClient->actions->triggerWorkflow(
|
||||
$owner,
|
||||
$repo,
|
||||
$workflowId,
|
||||
$inputs ?? [],
|
||||
$ref
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "Workflow {$workflowId} triggered successfully",
|
||||
'workflow_id' => $workflowId,
|
||||
'ref' => $ref,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to trigger workflow {$workflowId}");
|
||||
}
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_list_workflow_runs',
|
||||
description: 'List workflow runs for a repository'
|
||||
)]
|
||||
#[ConsoleCommand(
|
||||
name: 'gitea:workflows:runs',
|
||||
description: 'List workflow runs for a Gitea repository'
|
||||
)]
|
||||
public function listWorkflowRuns(
|
||||
string $owner,
|
||||
string $repo,
|
||||
?string $status = null,
|
||||
?int $workflowId = null,
|
||||
int $page = 1,
|
||||
int $limit = 30
|
||||
): GenericResult {
|
||||
try {
|
||||
$options = array_filter([
|
||||
'status' => $status,
|
||||
'workflow_id' => $workflowId,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
], fn($value) => $value !== null);
|
||||
|
||||
$runs = $this->giteaClient->actions->listRuns($owner, $repo, $options);
|
||||
|
||||
return GenericResult::success([
|
||||
'runs' => array_map(
|
||||
fn($run) => $this->formatWorkflowRun($run),
|
||||
$runs['workflow_runs'] ?? []
|
||||
),
|
||||
'count' => count($runs['workflow_runs'] ?? []),
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return GenericResult::fromException($e);
|
||||
}
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_get_workflow_run',
|
||||
description: 'Get details of a specific workflow run'
|
||||
)]
|
||||
#[ConsoleCommand(
|
||||
name: 'gitea:workflows:run',
|
||||
description: 'Get details of a specific Gitea workflow run'
|
||||
)]
|
||||
public function getWorkflowRun(string $owner, string $repo, int $runId): GenericResult
|
||||
{
|
||||
try {
|
||||
$run = $this->giteaClient->actions->getRun($owner, $repo, $runId);
|
||||
|
||||
return GenericResult::success([
|
||||
'run' => $this->formatWorkflowRun($run),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return GenericResult::fromException($e, [
|
||||
'context' => "Failed to get workflow run #{$runId}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_cancel_workflow_run',
|
||||
description: 'Cancel a running workflow'
|
||||
)]
|
||||
public function cancelWorkflowRun(string $owner, string $repo, int $runId): array
|
||||
{
|
||||
try {
|
||||
$this->giteaClient->actions->cancelRun($owner, $repo, $runId);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "Workflow run #{$runId} cancelled successfully",
|
||||
'run_id' => $runId,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to cancel workflow run #{$runId}");
|
||||
}
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_get_workflow_logs',
|
||||
description: 'Get logs of a workflow run'
|
||||
)]
|
||||
public function getWorkflowLogs(string $owner, string $repo, int $runId): array
|
||||
{
|
||||
try {
|
||||
$logs = $this->giteaClient->actions->getLogs($owner, $repo, $runId);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'logs' => $logs,
|
||||
'run_id' => $runId,
|
||||
'size' => strlen($logs),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to get workflow logs for run #{$runId}");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Git Remote Integration ====================
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_add_remote',
|
||||
description: 'Add Gitea repository as git remote'
|
||||
@@ -306,150 +469,130 @@ final readonly class GiteaTools
|
||||
string $repo,
|
||||
bool $useSsh = true
|
||||
): array {
|
||||
// Get repository info first
|
||||
$repoInfo = $this->getRepository($owner, $repo);
|
||||
try {
|
||||
// Get repository info first
|
||||
$repository = $this->giteaClient->repositories->get($owner, $repo);
|
||||
|
||||
if (! $repoInfo['success']) {
|
||||
return $repoInfo;
|
||||
}
|
||||
$url = $useSsh
|
||||
? $repository['ssh_url']
|
||||
: $repository['clone_url'];
|
||||
|
||||
$url = $useSsh
|
||||
? $repoInfo['repository']['ssh_url']
|
||||
: $repoInfo['repository']['clone_url'];
|
||||
|
||||
if (! $url) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Repository URL not found',
|
||||
];
|
||||
}
|
||||
|
||||
// Add remote via git command
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
$command = sprintf(
|
||||
'git remote add %s %s 2>&1',
|
||||
escapeshellarg($remoteName),
|
||||
escapeshellarg($url)
|
||||
);
|
||||
exec($command, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
// Check if remote already exists
|
||||
if (str_contains(implode("\n", $output), 'already exists')) {
|
||||
if (!$url) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Remote already exists',
|
||||
'suggestion' => "Use 'git remote set-url $remoteName $url' to update",
|
||||
'error' => 'Repository URL not found',
|
||||
];
|
||||
}
|
||||
|
||||
// Add remote via git command
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
$command = sprintf(
|
||||
'git remote add %s %s 2>&1',
|
||||
escapeshellarg($remoteName),
|
||||
escapeshellarg($url)
|
||||
);
|
||||
exec($command, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
// Check if remote already exists
|
||||
if (str_contains(implode("\n", $output), 'already exists')) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Remote already exists',
|
||||
'suggestion' => "Use 'git remote set-url {$remoteName} {$url}' to update",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Failed to add remote',
|
||||
'output' => implode("\n", $output),
|
||||
'exit_code' => $exitCode,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Failed to add remote',
|
||||
'output' => implode("\n", $output),
|
||||
'exit_code' => $exitCode,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'remote_name' => $remoteName,
|
||||
'url' => $url,
|
||||
'use_ssh' => $useSsh,
|
||||
];
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_webhook_create',
|
||||
description: 'Create a webhook for a repository'
|
||||
)]
|
||||
public function createWebhook(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $url,
|
||||
string $contentType = 'json',
|
||||
array $events = ['push'],
|
||||
bool $active = true,
|
||||
?string $secret = null
|
||||
): array {
|
||||
$hookUrl = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/hooks";
|
||||
|
||||
$data = [
|
||||
'type' => 'gitea',
|
||||
'config' => [
|
||||
'url' => $url,
|
||||
'content_type' => $contentType,
|
||||
'secret' => $secret ?? '',
|
||||
],
|
||||
'events' => $events,
|
||||
'active' => $active,
|
||||
];
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::POST, $hookUrl, $data);
|
||||
|
||||
if ($result['success']) {
|
||||
return [
|
||||
'success' => true,
|
||||
'webhook' => [
|
||||
'id' => $result['response']['id'] ?? null,
|
||||
'url' => $result['response']['config']['url'] ?? $url,
|
||||
'events' => $result['response']['events'] ?? $events,
|
||||
'active' => $result['response']['active'] ?? $active,
|
||||
'created_at' => $result['response']['created_at'] ?? null,
|
||||
],
|
||||
'remote_name' => $remoteName,
|
||||
'url' => $url,
|
||||
'use_ssh' => $useSsh,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return $this->formatError($e, "Failed to add remote {$remoteName}");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ==================== Private Helper Methods ====================
|
||||
|
||||
private function makeRequest(HttpMethod $method, string $url, ?array $data = null): array
|
||||
private function formatRepository(array $repo): array
|
||||
{
|
||||
try {
|
||||
$options = [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'Authorization' => 'Basic ' . base64_encode("{$this->giteaUsername}:{$this->giteaPassword}"),
|
||||
],
|
||||
'verify_ssl' => false, // For self-signed certificates
|
||||
];
|
||||
return [
|
||||
'id' => $repo['id'] ?? null,
|
||||
'name' => $repo['name'] ?? 'unknown',
|
||||
'full_name' => $repo['full_name'] ?? 'unknown',
|
||||
'description' => $repo['description'] ?? '',
|
||||
'private' => $repo['private'] ?? false,
|
||||
'clone_url' => $repo['clone_url'] ?? null,
|
||||
'ssh_url' => $repo['ssh_url'] ?? null,
|
||||
'html_url' => $repo['html_url'] ?? null,
|
||||
'default_branch' => $repo['default_branch'] ?? 'main',
|
||||
'created_at' => $repo['created_at'] ?? null,
|
||||
'updated_at' => $repo['updated_at'] ?? null,
|
||||
'stars_count' => $repo['stars_count'] ?? 0,
|
||||
'forks_count' => $repo['forks_count'] ?? 0,
|
||||
'open_issues_count' => $repo['open_issues_count'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
if ($data !== null) {
|
||||
$options['json'] = $data;
|
||||
}
|
||||
private function formatIssue(array $issue): array
|
||||
{
|
||||
return [
|
||||
'id' => $issue['id'] ?? null,
|
||||
'number' => $issue['number'] ?? null,
|
||||
'title' => $issue['title'] ?? 'Untitled',
|
||||
'body' => $issue['body'] ?? '',
|
||||
'state' => $issue['state'] ?? 'open',
|
||||
'labels' => array_map(
|
||||
fn($label) => $label['name'] ?? 'unknown',
|
||||
$issue['labels'] ?? []
|
||||
),
|
||||
'assignees' => array_map(
|
||||
fn($assignee) => $assignee['username'] ?? 'unknown',
|
||||
$issue['assignees'] ?? []
|
||||
),
|
||||
'html_url' => $issue['html_url'] ?? null,
|
||||
'created_at' => $issue['created_at'] ?? null,
|
||||
'updated_at' => $issue['updated_at'] ?? null,
|
||||
'closed_at' => $issue['closed_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request($method, $url, $options);
|
||||
private function formatWorkflowRun(array $run): array
|
||||
{
|
||||
return [
|
||||
'id' => $run['id'] ?? null,
|
||||
'name' => $run['name'] ?? 'Unnamed Workflow',
|
||||
'status' => $run['status'] ?? 'unknown',
|
||||
'conclusion' => $run['conclusion'] ?? null,
|
||||
'workflow_id' => $run['workflow_id'] ?? null,
|
||||
'event' => $run['event'] ?? null,
|
||||
'head_branch' => $run['head_branch'] ?? null,
|
||||
'head_sha' => $run['head_sha'] ?? null,
|
||||
'html_url' => $run['html_url'] ?? null,
|
||||
'created_at' => $run['created_at'] ?? null,
|
||||
'updated_at' => $run['updated_at'] ?? null,
|
||||
'run_started_at' => $run['run_started_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
$body = $response->getBody();
|
||||
|
||||
// Decode JSON response
|
||||
$decoded = json_decode($body, true);
|
||||
|
||||
if ($statusCode >= 200 && $statusCode < 300) {
|
||||
return [
|
||||
'success' => true,
|
||||
'response' => $decoded,
|
||||
'http_code' => $statusCode,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $decoded['message'] ?? 'HTTP error ' . $statusCode,
|
||||
'response' => $decoded,
|
||||
'http_code' => $statusCode,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Request failed: ' . $e->getMessage(),
|
||||
'exception' => get_class($e),
|
||||
];
|
||||
}
|
||||
private function formatError(\Exception $e, string $context): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $context,
|
||||
'message' => $e->getMessage(),
|
||||
'exception' => get_class($e),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,34 +4,25 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mcp\Tools;
|
||||
|
||||
use App\Framework\Config\Environment;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
use App\Infrastructure\Api\Gitea\GiteaClient;
|
||||
|
||||
/**
|
||||
* Initializer for Gitea MCP Tools
|
||||
*
|
||||
* Registers GiteaTools with the DI container using the GiteaClient service.
|
||||
* The GiteaClient is initialized separately with proper configuration via GiteaClientInitializer.
|
||||
*/
|
||||
final readonly class GiteaToolsInitializer
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClient $httpClient,
|
||||
private Environment $environment
|
||||
private GiteaClient $giteaClient
|
||||
) {
|
||||
}
|
||||
|
||||
#[Initializer]
|
||||
public function __invoke(): GiteaTools
|
||||
{
|
||||
// Get Gitea configuration from environment
|
||||
$giteaUrl = $this->environment->get('GITEA_URL', 'https://localhost:9443');
|
||||
$giteaUsername = $this->environment->get('GITEA_USERNAME', 'michael');
|
||||
$giteaPassword = $this->environment->get('GITEA_PASSWORD', 'GiteaAdmin2024');
|
||||
|
||||
return new GiteaTools(
|
||||
$this->httpClient,
|
||||
$giteaUrl,
|
||||
$giteaUsername,
|
||||
$giteaPassword
|
||||
);
|
||||
return new GiteaTools($this->giteaClient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +92,9 @@ final readonly class JobChainCommands
|
||||
foreach ($status['job_statuses'] as $jobStatus) {
|
||||
$canExecute = $jobStatus['can_execute'] ? '✅' : '⏳';
|
||||
$depStatus = "{$jobStatus['dependencies_satisfied']}/{$jobStatus['dependencies_total']} deps";
|
||||
$position = $jobStatus['position'] + 1;
|
||||
|
||||
echo " {$canExecute} Job {$jobStatus['position'] + 1}: {$jobStatus['job_id']} ({$depStatus})\n";
|
||||
echo " {$canExecute} Job {$position}: {$jobStatus['job_id']} ({$depStatus})\n";
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
@@ -167,7 +168,8 @@ final readonly class JobChainCommands
|
||||
echo "🔗 {$statusIcon} {$chain['name']} ({$chain['chain_id']})\n";
|
||||
echo " Status: {$chain['status']}\n";
|
||||
echo " Mode: {$chain['execution_mode']}\n";
|
||||
echo " Position: {$chain['job_position'] + 1}/{$chain['total_jobs']}\n";
|
||||
$position = $chain['job_position'] + 1;
|
||||
echo " Position: {$position}/{$chain['total_jobs']}\n";
|
||||
|
||||
if ($chain['next_job_after_current']) {
|
||||
echo " Next job: {$chain['next_job_after_current']}\n";
|
||||
|
||||
@@ -192,11 +192,12 @@ final readonly class JobDependencyCommands
|
||||
foreach ($health['issues'] as $issue) {
|
||||
echo " - {$issue['type']}: ";
|
||||
|
||||
match($issue['type']) {
|
||||
'stalled_chain' => echo "Chain {$issue['chain_id']} running for {$issue['hours_running']} hours\n",
|
||||
'many_unsatisfied_dependencies' => echo "Job {$issue['job_id']} has {$issue['unsatisfied_count']} unsatisfied dependencies\n",
|
||||
default => echo "Unknown issue\n"
|
||||
$message = match($issue['type']) {
|
||||
'stalled_chain' => "Chain {$issue['chain_id']} running for {$issue['hours_running']} hours\n",
|
||||
'many_unsatisfied_dependencies' => "Job {$issue['job_id']} has {$issue['unsatisfied_count']} unsatisfied dependencies\n",
|
||||
default => "Unknown issue\n"
|
||||
};
|
||||
echo $message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +99,22 @@ final class MethodCache implements \App\Framework\Reflection\Contracts\Reflectio
|
||||
if (! isset($this->methodCache[$key])) {
|
||||
/** @var class-string $classNameString */
|
||||
$classNameString = $className->getFullyQualified();
|
||||
$class = new \ReflectionClass($classNameString);
|
||||
$this->methodCache[$key] = $class->getMethods($filter);
|
||||
|
||||
try {
|
||||
$class = new \ReflectionClass($classNameString);
|
||||
$this->methodCache[$key] = $class->getMethods($filter);
|
||||
} catch (\ReflectionException $e) {
|
||||
// Provide context about which class failed to reflect
|
||||
$errorMessage = sprintf(
|
||||
'Failed to reflect class "%s" in MethodCache::getNativeMethods(): %s',
|
||||
$classNameString,
|
||||
$e->getMessage()
|
||||
);
|
||||
error_log($errorMessage);
|
||||
|
||||
// Re-throw with more context
|
||||
throw new \ReflectionException($errorMessage, $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->methodCache[$key];
|
||||
|
||||
180
src/Framework/Router/GenericResult.php
Normal file
180
src/Framework/Router/GenericResult.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Router;
|
||||
|
||||
use App\Framework\Console\ConsoleOutputInterface;
|
||||
use App\Framework\Console\ExitCode;
|
||||
use App\Framework\Console\Result\ConsoleResult;
|
||||
use App\Framework\Console\Result\TextResult;
|
||||
use App\Framework\Mcp\Core\ValueObjects\ToolResult;
|
||||
|
||||
/**
|
||||
* Generic Action Result
|
||||
*
|
||||
* Universal result object that can be converted to both ToolResult (MCP)
|
||||
* and ConsoleResult (console commands). This enables methods to have dual
|
||||
* attributes (#[McpTool] and #[ConsoleCommand]) with a single return type.
|
||||
*
|
||||
* Architecture:
|
||||
* - Methods return GenericResult
|
||||
* - MCP context: Automatically converts to ToolResult
|
||||
* - Console context: Automatically converts to ConsoleResult (TextResult)
|
||||
*
|
||||
* Example:
|
||||
* #[McpTool(name: 'list_users')]
|
||||
* #[ConsoleCommand(name: 'users:list')]
|
||||
* public function listUsers(): GenericResult
|
||||
* {
|
||||
* return GenericResult::success(['users' => $users]);
|
||||
* }
|
||||
*/
|
||||
final readonly class GenericResult implements ActionResult, ConsoleResult
|
||||
{
|
||||
public readonly array $data;
|
||||
public readonly ExitCode $exitCode;
|
||||
|
||||
/**
|
||||
* Create generic result
|
||||
*
|
||||
* Use factory methods (success/failure/error) for convenience.
|
||||
*/
|
||||
public function __construct(
|
||||
public mixed $payload,
|
||||
public bool $success,
|
||||
public ?string $error = null,
|
||||
public array $metadata = [],
|
||||
?ExitCode $exitCode = null
|
||||
) {
|
||||
$this->exitCode = $exitCode ?? ($success ? ExitCode::SUCCESS : ExitCode::FAILURE);
|
||||
$this->data = [
|
||||
'success' => $this->success,
|
||||
'payload' => $this->payload,
|
||||
'error' => $this->error,
|
||||
'metadata' => $this->metadata,
|
||||
'exit_code' => $this->exitCode->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create success result
|
||||
*
|
||||
* @param mixed $payload Result data
|
||||
* @param array $metadata Additional metadata
|
||||
*/
|
||||
public static function success(mixed $payload, array $metadata = []): self
|
||||
{
|
||||
return new self(
|
||||
payload: $payload,
|
||||
success: true,
|
||||
metadata: $metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create failure result
|
||||
*
|
||||
* @param string $error Error message
|
||||
* @param mixed $payload Optional partial data
|
||||
* @param array $metadata Additional metadata
|
||||
*/
|
||||
public static function failure(string $error, mixed $payload = null, array $metadata = []): self
|
||||
{
|
||||
return new self(
|
||||
payload: $payload,
|
||||
success: false,
|
||||
error: $error,
|
||||
metadata: $metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create failure from exception
|
||||
*
|
||||
* @param \Throwable $exception Exception to convert
|
||||
* @param array $metadata Additional metadata
|
||||
*/
|
||||
public static function fromException(\Throwable $exception, array $metadata = []): self
|
||||
{
|
||||
return new self(
|
||||
payload: null,
|
||||
success: false,
|
||||
error: $exception->getMessage(),
|
||||
metadata: array_merge($metadata, [
|
||||
'exception_type' => get_class($exception),
|
||||
'exception_file' => $exception->getFile(),
|
||||
'exception_line' => $exception->getLine(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to ToolResult for MCP context
|
||||
*
|
||||
* Automatic conversion when accessed via MCP tools.
|
||||
*/
|
||||
public function toToolResult(): ToolResult
|
||||
{
|
||||
if ($this->success) {
|
||||
return ToolResult::success(
|
||||
data: $this->payload,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
return ToolResult::failure(
|
||||
error: $this->error ?? 'Unknown error',
|
||||
data: $this->payload,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to ConsoleResult for console context
|
||||
*
|
||||
* Automatic conversion when accessed via console commands.
|
||||
*/
|
||||
public function toConsoleResult(): ConsoleResult
|
||||
{
|
||||
if ($this->success) {
|
||||
$message = $this->formatSuccessMessage();
|
||||
return TextResult::success($message);
|
||||
}
|
||||
|
||||
return TextResult::error($this->error ?? 'Unknown error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render to console output (ConsoleResult interface)
|
||||
*
|
||||
* Delegates to TextResult for rendering.
|
||||
*/
|
||||
public function render(ConsoleOutputInterface $output): void
|
||||
{
|
||||
$this->toConsoleResult()->render($output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format success message for console output
|
||||
*/
|
||||
private function formatSuccessMessage(): string
|
||||
{
|
||||
if (is_string($this->payload)) {
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
if (is_array($this->payload)) {
|
||||
// Check for common success message keys
|
||||
if (isset($this->payload['message'])) {
|
||||
return (string) $this->payload['message'];
|
||||
}
|
||||
|
||||
// Format array data as JSON for readability
|
||||
return json_encode($this->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
// Fallback: Generic success message
|
||||
return 'Operation completed successfully';
|
||||
}
|
||||
}
|
||||
41
src/Framework/Storage/Exceptions/BucketNotFoundException.php
Normal file
41
src/Framework/Storage/Exceptions/BucketNotFoundException.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
|
||||
/**
|
||||
* Exception thrown when a bucket is not found
|
||||
*/
|
||||
final class BucketNotFoundException extends StorageException
|
||||
{
|
||||
public function __construct(
|
||||
string $bucket,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
$context = ExceptionContext::forOperation('storage.bucket', 'ObjectStorage')
|
||||
->withData(['bucket' => $bucket]);
|
||||
|
||||
parent::__construct(
|
||||
message: "Bucket not found: {$bucket}",
|
||||
context: $context,
|
||||
errorCode: ErrorCode::NOT_FOUND,
|
||||
previous: $previous,
|
||||
code: 404
|
||||
);
|
||||
}
|
||||
|
||||
public static function for(string $bucket, ?\Throwable $previous = null): self
|
||||
{
|
||||
return new self($bucket, $previous);
|
||||
}
|
||||
|
||||
public function getBucket(): string
|
||||
{
|
||||
return $this->getContext()->getData()['bucket'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
50
src/Framework/Storage/Exceptions/ObjectNotFoundException.php
Normal file
50
src/Framework/Storage/Exceptions/ObjectNotFoundException.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
|
||||
/**
|
||||
* Exception thrown when an object is not found in storage
|
||||
*/
|
||||
final class ObjectNotFoundException extends StorageException
|
||||
{
|
||||
public function __construct(
|
||||
string $bucket,
|
||||
string $key,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
$context = ExceptionContext::forOperation('storage.get', 'ObjectStorage')
|
||||
->withData([
|
||||
'bucket' => $bucket,
|
||||
'key' => $key,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: "Object not found: {$bucket}/{$key}",
|
||||
context: $context,
|
||||
errorCode: ErrorCode::NOT_FOUND,
|
||||
previous: $previous,
|
||||
code: 404
|
||||
);
|
||||
}
|
||||
|
||||
public static function for(string $bucket, string $key, ?\Throwable $previous = null): self
|
||||
{
|
||||
return new self($bucket, $key, $previous);
|
||||
}
|
||||
|
||||
public function getBucket(): string
|
||||
{
|
||||
return $this->getContext()->getData()['bucket'] ?? '';
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->getContext()->getData()['key'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
|
||||
/**
|
||||
* Exception thrown when storage connection fails
|
||||
*/
|
||||
final class StorageConnectionException extends StorageException
|
||||
{
|
||||
public function __construct(
|
||||
string $endpoint,
|
||||
string $reason = '',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
$context = ExceptionContext::forOperation('storage.connect', 'Storage')
|
||||
->withData([
|
||||
'endpoint' => $endpoint,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
$message = "Failed to connect to storage endpoint: {$endpoint}";
|
||||
if ($reason !== '') {
|
||||
$message .= " ({$reason})";
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
errorCode: ErrorCode::CONNECTION_FAILED,
|
||||
previous: $previous,
|
||||
code: 503
|
||||
);
|
||||
}
|
||||
|
||||
public static function for(string $endpoint, string $reason = '', ?\Throwable $previous = null): self
|
||||
{
|
||||
return new self($endpoint, $reason, $previous);
|
||||
}
|
||||
|
||||
public function getEndpoint(): string
|
||||
{
|
||||
return $this->getContext()->getData()['endpoint'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
87
src/Framework/Storage/Exceptions/StorageException.php
Normal file
87
src/Framework/Storage/Exceptions/StorageException.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\ExceptionMetadata;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Base exception for Storage operations
|
||||
*
|
||||
* All storage-related exceptions should extend this class.
|
||||
*/
|
||||
class StorageException extends FrameworkException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
ExceptionContext $context,
|
||||
?ErrorCode $errorCode = null,
|
||||
?\Throwable $previous = null,
|
||||
int $code = 0,
|
||||
?ExceptionMetadata $metadata = null
|
||||
) {
|
||||
parent::__construct($message, $context, $code, $previous, $errorCode, $metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for bucket/key not found
|
||||
*/
|
||||
public static function notFound(string $bucket, string $key, ?\Throwable $previous = null): self
|
||||
{
|
||||
$context = ExceptionContext::forOperation('storage.get', 'Storage')
|
||||
->withData([
|
||||
'bucket' => $bucket,
|
||||
'key' => $key,
|
||||
]);
|
||||
|
||||
return new self(
|
||||
message: "Object not found: {$bucket}/{$key}",
|
||||
context: $context,
|
||||
errorCode: ErrorCode::NOT_FOUND,
|
||||
previous: $previous,
|
||||
code: 404
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for connection errors
|
||||
*/
|
||||
public static function connectionFailed(string $endpoint, ?\Throwable $previous = null): self
|
||||
{
|
||||
$context = ExceptionContext::forOperation('storage.connect', 'Storage')
|
||||
->withData(['endpoint' => $endpoint]);
|
||||
|
||||
return new self(
|
||||
message: "Failed to connect to storage endpoint: {$endpoint}",
|
||||
context: $context,
|
||||
errorCode: ErrorCode::CONNECTION_FAILED,
|
||||
previous: $previous,
|
||||
code: 503
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for operation failures
|
||||
*/
|
||||
public static function operationFailed(string $operation, string $bucket, string $key, ?\Throwable $previous = null): self
|
||||
{
|
||||
$context = ExceptionContext::forOperation("storage.{$operation}", 'Storage')
|
||||
->withData([
|
||||
'bucket' => $bucket,
|
||||
'key' => $key,
|
||||
]);
|
||||
|
||||
return new self(
|
||||
message: "Storage operation failed: {$operation} on {$bucket}/{$key}",
|
||||
context: $context,
|
||||
errorCode: ErrorCode::OPERATION_FAILED,
|
||||
previous: $previous,
|
||||
code: 500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
|
||||
/**
|
||||
* Exception thrown when a storage operation fails
|
||||
*/
|
||||
final class StorageOperationException extends StorageException
|
||||
{
|
||||
public function __construct(
|
||||
string $operation,
|
||||
string $bucket,
|
||||
string $key,
|
||||
string $reason = '',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
$context = ExceptionContext::forOperation("storage.{$operation}", 'Storage')
|
||||
->withData([
|
||||
'bucket' => $bucket,
|
||||
'key' => $key,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
$message = "Storage operation '{$operation}' failed for {$bucket}/{$key}";
|
||||
if ($reason !== '') {
|
||||
$message .= ": {$reason}";
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
errorCode: ErrorCode::OPERATION_FAILED,
|
||||
previous: $previous,
|
||||
code: 500
|
||||
);
|
||||
}
|
||||
|
||||
public static function for(string $operation, string $bucket, string $key, string $reason = '', ?\Throwable $previous = null): self
|
||||
{
|
||||
return new self($operation, $bucket, $key, $reason, $previous);
|
||||
}
|
||||
|
||||
public function getOperation(): string
|
||||
{
|
||||
return $this->getContext()->getOperation();
|
||||
}
|
||||
|
||||
public function getBucket(): string
|
||||
{
|
||||
return $this->getContext()->getData()['bucket'] ?? '';
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->getContext()->getData()['key'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
360
src/Framework/Storage/FilesystemObjectStorage.php
Normal file
360
src/Framework/Storage/FilesystemObjectStorage.php
Normal file
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage;
|
||||
|
||||
use App\Framework\Core\ValueObjects\FileSize;
|
||||
use App\Framework\Core\ValueObjects\Hash;
|
||||
use App\Framework\Core\ValueObjects\HashAlgorithm;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Filesystem\Storage;
|
||||
use App\Framework\Http\CustomMimeType;
|
||||
use App\Framework\Http\MimeType;
|
||||
use App\Framework\Storage\Exceptions\ObjectNotFoundException;
|
||||
use App\Framework\Storage\Exceptions\StorageOperationException;
|
||||
use App\Framework\Storage\ValueObjects\BucketName;
|
||||
use App\Framework\Storage\ValueObjects\ObjectKey;
|
||||
use App\Framework\Storage\ValueObjects\ObjectMetadata;
|
||||
use DateInterval;
|
||||
|
||||
/**
|
||||
* Filesystem-based Object Storage implementation
|
||||
*
|
||||
* Maps S3-style buckets/keys to filesystem directories/files:
|
||||
* - Buckets = directories
|
||||
* - Keys = files within bucket directories
|
||||
*/
|
||||
final readonly class FilesystemObjectStorage implements ObjectStorage, StreamableObjectStorage
|
||||
{
|
||||
private string $basePath;
|
||||
public function __construct(
|
||||
private Storage $storage,
|
||||
string $basePath = '/'
|
||||
) {
|
||||
// Normalize base path
|
||||
$this->basePath = rtrim($basePath, '/');
|
||||
}
|
||||
|
||||
public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo
|
||||
{
|
||||
$bucketName = BucketName::fromString($bucket);
|
||||
$objectKey = ObjectKey::fromString($key);
|
||||
$path = $this->buildPath($bucket, $key);
|
||||
|
||||
try {
|
||||
// Ensure bucket directory exists
|
||||
$bucketPath = $this->buildBucketPath($bucket);
|
||||
if (! $this->storage->exists($bucketPath)) {
|
||||
$this->storage->createDirectory($bucketPath);
|
||||
}
|
||||
|
||||
// Store content
|
||||
$this->storage->put($path, $body);
|
||||
|
||||
// Get file metadata
|
||||
$size = FileSize::fromBytes($this->storage->size($path));
|
||||
$lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path));
|
||||
$mimeTypeString = $this->storage->getMimeType($path);
|
||||
|
||||
// Generate ETag (SHA256 hash of content)
|
||||
$etag = Hash::sha256($body);
|
||||
|
||||
// Convert MIME type string to Value Object
|
||||
$contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString);
|
||||
|
||||
$metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []);
|
||||
|
||||
return new ObjectInfo(
|
||||
bucket: $bucketName,
|
||||
key: $objectKey,
|
||||
etag: $etag,
|
||||
size: $size,
|
||||
contentType: $contentType,
|
||||
lastModified: $lastModified,
|
||||
metadata: $metadata,
|
||||
versionId: null
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
throw StorageOperationException::for('put', $bucket, $key, $e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function get(string $bucket, string $key): string
|
||||
{
|
||||
$path = $this->buildPath($bucket, $key);
|
||||
|
||||
try {
|
||||
if (! $this->storage->exists($path)) {
|
||||
throw ObjectNotFoundException::for($bucket, $key);
|
||||
}
|
||||
|
||||
return $this->storage->get($path);
|
||||
} catch (ObjectNotFoundException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw StorageOperationException::for('get', $bucket, $key, $e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function stream(string $bucket, string $key)
|
||||
{
|
||||
// Backward compatibility: returns temporary stream
|
||||
return $this->openReadStream($bucket, $key);
|
||||
}
|
||||
|
||||
public function getToStream(string $bucket, string $key, $destination, array $opts = []): int
|
||||
{
|
||||
$path = $this->buildPath($bucket, $key);
|
||||
|
||||
try {
|
||||
if (! $this->storage->exists($path)) {
|
||||
throw ObjectNotFoundException::for($bucket, $key);
|
||||
}
|
||||
|
||||
// Validate destination stream
|
||||
if (! is_resource($destination)) {
|
||||
throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream');
|
||||
}
|
||||
|
||||
// Use Storage's readStream if available
|
||||
if (method_exists($this->storage, 'readStream')) {
|
||||
$sourceStream = $this->storage->readStream($path);
|
||||
$bufferSize = $opts['bufferSize'] ?? 8192;
|
||||
|
||||
try {
|
||||
$bytesWritten = stream_copy_to_stream($sourceStream, $destination, null, $bufferSize);
|
||||
if ($bytesWritten === false) {
|
||||
throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to copy stream');
|
||||
}
|
||||
|
||||
return $bytesWritten;
|
||||
} finally {
|
||||
fclose($sourceStream);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: read content and write to stream
|
||||
$content = $this->storage->get($path);
|
||||
$bytesWritten = fwrite($destination, $content);
|
||||
if ($bytesWritten === false) {
|
||||
throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to write to destination stream');
|
||||
}
|
||||
|
||||
return $bytesWritten;
|
||||
} catch (ObjectNotFoundException $e) {
|
||||
throw $e;
|
||||
} catch (StorageOperationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw StorageOperationException::for('getToStream', $bucket, $key, $e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo
|
||||
{
|
||||
$path = $this->buildPath($bucket, $key);
|
||||
|
||||
try {
|
||||
// Validate source stream
|
||||
if (! is_resource($source)) {
|
||||
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream');
|
||||
}
|
||||
|
||||
// Ensure bucket directory exists
|
||||
$bucketPath = $this->buildBucketPath($bucket);
|
||||
if (! $this->storage->exists($bucketPath)) {
|
||||
$this->storage->createDirectory($bucketPath);
|
||||
}
|
||||
|
||||
// Use Storage's putStream if available
|
||||
if (method_exists($this->storage, 'putStream')) {
|
||||
$this->storage->putStream($path, $source);
|
||||
} else {
|
||||
// Fallback: read stream content and use put()
|
||||
$content = stream_get_contents($source);
|
||||
if ($content === false) {
|
||||
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Failed to read from source stream');
|
||||
}
|
||||
|
||||
$this->storage->put($path, $content);
|
||||
}
|
||||
|
||||
// Reuse head() logic to get ObjectInfo
|
||||
return $this->head($bucket, $key);
|
||||
} catch (StorageOperationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw StorageOperationException::for('putFromStream', $bucket, $key, $e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function openReadStream(string $bucket, string $key)
|
||||
{
|
||||
$path = $this->buildPath($bucket, $key);
|
||||
|
||||
try {
|
||||
if (! $this->storage->exists($path)) {
|
||||
throw ObjectNotFoundException::for($bucket, $key);
|
||||
}
|
||||
|
||||
// Use Storage's readStream if available
|
||||
if (method_exists($this->storage, 'readStream')) {
|
||||
return $this->storage->readStream($path);
|
||||
}
|
||||
|
||||
// Fallback: create stream from file content
|
||||
$content = $this->storage->get($path);
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
if ($stream === false) {
|
||||
throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream');
|
||||
}
|
||||
|
||||
fwrite($stream, $content);
|
||||
rewind($stream);
|
||||
|
||||
return $stream;
|
||||
} catch (ObjectNotFoundException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw StorageOperationException::for('openReadStream', $bucket, $key, $e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function head(string $bucket, string $key): ObjectInfo
|
||||
{
|
||||
$bucketName = BucketName::fromString($bucket);
|
||||
$objectKey = ObjectKey::fromString($key);
|
||||
$path = $this->buildPath($bucket, $key);
|
||||
|
||||
try {
|
||||
if (! $this->storage->exists($path)) {
|
||||
throw ObjectNotFoundException::for($bucket, $key);
|
||||
}
|
||||
|
||||
$size = FileSize::fromBytes($this->storage->size($path));
|
||||
$lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path));
|
||||
$mimeTypeString = $this->storage->getMimeType($path);
|
||||
|
||||
// Read content to generate ETag (could be optimized to read only if needed)
|
||||
$content = $this->storage->get($path);
|
||||
$etag = Hash::sha256($content);
|
||||
|
||||
// Convert MIME type string to Value Object
|
||||
$contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString);
|
||||
|
||||
return new ObjectInfo(
|
||||
bucket: $bucketName,
|
||||
key: $objectKey,
|
||||
etag: $etag,
|
||||
size: $size,
|
||||
contentType: $contentType,
|
||||
lastModified: $lastModified,
|
||||
metadata: ObjectMetadata::empty(),
|
||||
versionId: null
|
||||
);
|
||||
} catch (ObjectNotFoundException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw StorageOperationException::for('head', $bucket, $key, $e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(string $bucket, string $key): void
|
||||
{
|
||||
$path = $this->buildPath($bucket, $key);
|
||||
|
||||
try {
|
||||
if (! $this->storage->exists($path)) {
|
||||
// Object doesn't exist, but that's OK for delete operations
|
||||
return;
|
||||
}
|
||||
|
||||
$this->storage->delete($path);
|
||||
} catch (\Throwable $e) {
|
||||
throw StorageOperationException::for('delete', $bucket, $key, $e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function exists(string $bucket, string $key): bool
|
||||
{
|
||||
$path = $this->buildPath($bucket, $key);
|
||||
|
||||
return $this->storage->exists($path);
|
||||
}
|
||||
|
||||
public function url(string $bucket, string $key): ?string
|
||||
{
|
||||
// Filesystem storage doesn't have public URLs
|
||||
return null;
|
||||
}
|
||||
|
||||
public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string
|
||||
{
|
||||
// Filesystem storage doesn't support presigned URLs
|
||||
throw StorageOperationException::for(
|
||||
'temporaryUrl',
|
||||
$bucket,
|
||||
$key,
|
||||
'Temporary URLs are not supported for filesystem storage'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build filesystem path for bucket/key
|
||||
*/
|
||||
private function buildPath(string $bucket, string $key): string
|
||||
{
|
||||
// Sanitize bucket name (prevent path traversal)
|
||||
$bucket = $this->sanitizeBucketName($bucket);
|
||||
$key = ltrim($key, '/');
|
||||
|
||||
// Sanitize key (prevent path traversal)
|
||||
$key = $this->sanitizeKey($key);
|
||||
|
||||
return $this->basePath . '/' . $bucket . '/' . $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build filesystem path for bucket directory
|
||||
*/
|
||||
private function buildBucketPath(string $bucket): string
|
||||
{
|
||||
$bucket = $this->sanitizeBucketName($bucket);
|
||||
|
||||
return $this->basePath . '/' . $bucket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize bucket name to prevent path traversal
|
||||
*/
|
||||
private function sanitizeBucketName(string $bucket): string
|
||||
{
|
||||
// Remove any path separators and dangerous characters
|
||||
$bucket = str_replace(['/', '\\', '..'], '', $bucket);
|
||||
$bucket = trim($bucket, '.');
|
||||
|
||||
if ($bucket === '' || $bucket === '.') {
|
||||
throw StorageOperationException::for('sanitize', $bucket, '', 'Invalid bucket name');
|
||||
}
|
||||
|
||||
return $bucket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize key to prevent path traversal
|
||||
*/
|
||||
private function sanitizeKey(string $key): string
|
||||
{
|
||||
// Remove leading slashes but preserve internal structure
|
||||
$key = ltrim($key, '/');
|
||||
|
||||
// Prevent directory traversal
|
||||
if (str_contains($key, '..')) {
|
||||
throw StorageOperationException::for('sanitize', '', $key, 'Path traversal detected in key');
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
|
||||
229
src/Framework/Storage/InMemoryObjectStorage.php
Normal file
229
src/Framework/Storage/InMemoryObjectStorage.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage;
|
||||
|
||||
use App\Framework\Core\ValueObjects\FileSize;
|
||||
use App\Framework\Core\ValueObjects\Hash;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Http\CustomMimeType;
|
||||
use App\Framework\Http\MimeType;
|
||||
use App\Framework\Storage\Exceptions\ObjectNotFoundException;
|
||||
use App\Framework\Storage\Exceptions\StorageOperationException;
|
||||
use App\Framework\Storage\ValueObjects\BucketName;
|
||||
use App\Framework\Storage\ValueObjects\ObjectKey;
|
||||
use App\Framework\Storage\ValueObjects\ObjectMetadata;
|
||||
use DateInterval;
|
||||
|
||||
/**
|
||||
* In-Memory Object Storage implementation for testing
|
||||
*
|
||||
* Stores objects in memory without any I/O operations.
|
||||
* Structure: array<string, array<string, array{content: string, metadata: array}>>
|
||||
*/
|
||||
final class InMemoryObjectStorage implements ObjectStorage, StreamableObjectStorage
|
||||
{
|
||||
/**
|
||||
* Storage structure: [bucket => [key => [content, metadata, timestamp]]]
|
||||
*
|
||||
* @var array<string, array<string, array{content: string, metadata: array, timestamp: int}>>
|
||||
*/
|
||||
private array $storage = [];
|
||||
|
||||
public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo
|
||||
{
|
||||
$bucketName = BucketName::fromString($bucket);
|
||||
$objectKey = ObjectKey::fromString($key);
|
||||
|
||||
if (! isset($this->storage[$bucket])) {
|
||||
$this->storage[$bucket] = [];
|
||||
}
|
||||
|
||||
$etag = Hash::sha256($body);
|
||||
$size = FileSize::fromBytes(strlen($body));
|
||||
$timestamp = Timestamp::now();
|
||||
$metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []);
|
||||
|
||||
$contentType = null;
|
||||
if (isset($opts['contentType'])) {
|
||||
$contentType = MimeType::tryFrom($opts['contentType']) ?? CustomMimeType::fromString($opts['contentType']);
|
||||
} else {
|
||||
$contentType = MimeType::APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
|
||||
$this->storage[$bucket][$key] = [
|
||||
'content' => $body,
|
||||
'metadata' => $metadata->toArray(),
|
||||
'timestamp' => $timestamp->toTimestamp(),
|
||||
];
|
||||
|
||||
return new ObjectInfo(
|
||||
bucket: $bucketName,
|
||||
key: $objectKey,
|
||||
etag: $etag,
|
||||
size: $size,
|
||||
contentType: $contentType,
|
||||
lastModified: $timestamp,
|
||||
metadata: $metadata,
|
||||
versionId: null
|
||||
);
|
||||
}
|
||||
|
||||
public function get(string $bucket, string $key): string
|
||||
{
|
||||
if (! isset($this->storage[$bucket][$key])) {
|
||||
throw ObjectNotFoundException::for($bucket, $key);
|
||||
}
|
||||
|
||||
return $this->storage[$bucket][$key]['content'];
|
||||
}
|
||||
|
||||
public function stream(string $bucket, string $key)
|
||||
{
|
||||
// Backward compatibility: returns temporary stream
|
||||
return $this->openReadStream($bucket, $key);
|
||||
}
|
||||
|
||||
public function getToStream(string $bucket, string $key, $destination, array $opts = []): int
|
||||
{
|
||||
if (! is_resource($destination)) {
|
||||
throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream');
|
||||
}
|
||||
|
||||
$content = $this->get($bucket, $key);
|
||||
$bytesWritten = fwrite($destination, $content);
|
||||
|
||||
if ($bytesWritten === false) {
|
||||
throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to write to destination stream');
|
||||
}
|
||||
|
||||
return $bytesWritten;
|
||||
}
|
||||
|
||||
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo
|
||||
{
|
||||
if (! is_resource($source)) {
|
||||
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream');
|
||||
}
|
||||
|
||||
$content = stream_get_contents($source);
|
||||
if ($content === false) {
|
||||
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Failed to read from source stream');
|
||||
}
|
||||
|
||||
return $this->put($bucket, $key, $content, $opts);
|
||||
}
|
||||
|
||||
public function openReadStream(string $bucket, string $key)
|
||||
{
|
||||
$content = $this->get($bucket, $key);
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
if ($stream === false) {
|
||||
throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream');
|
||||
}
|
||||
|
||||
fwrite($stream, $content);
|
||||
rewind($stream);
|
||||
|
||||
return $stream;
|
||||
}
|
||||
|
||||
public function head(string $bucket, string $key): ObjectInfo
|
||||
{
|
||||
$bucketName = BucketName::fromString($bucket);
|
||||
$objectKey = ObjectKey::fromString($key);
|
||||
|
||||
if (! isset($this->storage[$bucket][$key])) {
|
||||
throw ObjectNotFoundException::for($bucket, $key);
|
||||
}
|
||||
|
||||
$entry = $this->storage[$bucket][$key];
|
||||
$content = $entry['content'];
|
||||
$etag = Hash::sha256($content);
|
||||
$size = FileSize::fromBytes(strlen($content));
|
||||
$lastModified = Timestamp::fromTimestamp($entry['timestamp']);
|
||||
$metadata = ObjectMetadata::fromArray($entry['metadata']);
|
||||
|
||||
return new ObjectInfo(
|
||||
bucket: $bucketName,
|
||||
key: $objectKey,
|
||||
etag: $etag,
|
||||
size: $size,
|
||||
contentType: MimeType::APPLICATION_OCTET_STREAM,
|
||||
lastModified: $lastModified,
|
||||
metadata: $metadata,
|
||||
versionId: null
|
||||
);
|
||||
}
|
||||
|
||||
public function delete(string $bucket, string $key): void
|
||||
{
|
||||
if (! isset($this->storage[$bucket][$key])) {
|
||||
// Object doesn't exist, but that's OK for delete operations
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->storage[$bucket][$key]);
|
||||
|
||||
// Clean up empty bucket
|
||||
if (empty($this->storage[$bucket])) {
|
||||
unset($this->storage[$bucket]);
|
||||
}
|
||||
}
|
||||
|
||||
public function exists(string $bucket, string $key): bool
|
||||
{
|
||||
return isset($this->storage[$bucket][$key]);
|
||||
}
|
||||
|
||||
public function url(string $bucket, string $key): ?string
|
||||
{
|
||||
// In-memory storage doesn't have URLs
|
||||
return null;
|
||||
}
|
||||
|
||||
public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string
|
||||
{
|
||||
// In-memory storage doesn't support presigned URLs
|
||||
throw StorageOperationException::for(
|
||||
'temporaryUrl',
|
||||
$bucket,
|
||||
$key,
|
||||
'Temporary URLs are not supported for in-memory storage'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored objects (for testing)
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->storage = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all buckets (for testing)
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function listBuckets(): array
|
||||
{
|
||||
return array_keys($this->storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all keys in a bucket (for testing)
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function listKeys(string $bucket): array
|
||||
{
|
||||
if (! isset($this->storage[$bucket])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_keys($this->storage[$bucket]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage;
|
||||
|
||||
use App\Framework\Core\ValueObjects\FileSize;
|
||||
use App\Framework\Core\ValueObjects\Hash;
|
||||
use App\Framework\Core\ValueObjects\HashAlgorithm;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Http\CustomMimeType;
|
||||
use App\Framework\Http\MimeType;
|
||||
use App\Framework\Http\MimeTypeInterface;
|
||||
use App\Framework\Storage\ValueObjects\BucketName;
|
||||
use App\Framework\Storage\ValueObjects\ObjectKey;
|
||||
use App\Framework\Storage\ValueObjects\ObjectMetadata;
|
||||
use App\Framework\Storage\ValueObjects\VersionId;
|
||||
|
||||
/**
|
||||
* Object Information Value Object
|
||||
*
|
||||
* Contains metadata about a stored object (bucket, key, size, content type, etc.)
|
||||
* Uses Value Objects for type safety and validation.
|
||||
*/
|
||||
final readonly class ObjectInfo
|
||||
{
|
||||
public function __construct(
|
||||
public string $bucket,
|
||||
public string $key,
|
||||
public ?string $etag = null, // z.B. sha256
|
||||
public ?int $size = null,
|
||||
public ?string $contentType = null,
|
||||
public array $metadata = [], // frei (owner, width, height, …)
|
||||
public ?string $versionId = null // S3: echt, FS: emuliert/optional
|
||||
) {}
|
||||
public BucketName $bucket,
|
||||
public ObjectKey $key,
|
||||
public ?Hash $etag = null,
|
||||
public ?FileSize $size = null,
|
||||
public ?MimeTypeInterface $contentType = null,
|
||||
public ?Timestamp $lastModified = null,
|
||||
public ObjectMetadata $metadata = new ObjectMetadata(),
|
||||
public ?VersionId $versionId = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ObjectInfo from legacy format (for backward compatibility)
|
||||
*
|
||||
* @param array<string, mixed> $legacyMetadata
|
||||
*/
|
||||
public static function fromLegacy(
|
||||
string $bucket,
|
||||
string $key,
|
||||
?string $etag = null,
|
||||
?int $size = null,
|
||||
?string $contentType = null,
|
||||
array $legacyMetadata = [],
|
||||
?string $versionId = null,
|
||||
?int $lastModified = null
|
||||
): self {
|
||||
$bucketName = BucketName::fromString($bucket);
|
||||
$objectKey = ObjectKey::fromString($key);
|
||||
|
||||
$hash = $etag !== null ? Hash::fromString($etag, HashAlgorithm::SHA256) : null;
|
||||
$fileSize = $size !== null ? FileSize::fromBytes($size) : null;
|
||||
|
||||
$mimeType = null;
|
||||
if ($contentType !== null) {
|
||||
$mimeType = MimeType::tryFrom($contentType) ?? CustomMimeType::fromString($contentType);
|
||||
}
|
||||
|
||||
$timestamp = $lastModified !== null ? Timestamp::fromTimestamp($lastModified) : null;
|
||||
$metadata = ObjectMetadata::fromArray($legacyMetadata);
|
||||
|
||||
$versionIdObj = $versionId !== null ? VersionId::fromString($versionId) : null;
|
||||
|
||||
return new self(
|
||||
bucket: $bucketName,
|
||||
key: $objectKey,
|
||||
etag: $hash,
|
||||
size: $fileSize,
|
||||
contentType: $mimeType,
|
||||
lastModified: $timestamp,
|
||||
metadata: $metadata,
|
||||
versionId: $versionIdObj
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bucket name as string
|
||||
*/
|
||||
public function getBucketName(): string
|
||||
{
|
||||
return $this->bucket->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object key as string
|
||||
*/
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ETag as string (if available)
|
||||
*/
|
||||
public function getEtag(): ?string
|
||||
{
|
||||
return $this->etag?->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get size in bytes (if available)
|
||||
*/
|
||||
public function getSizeBytes(): ?int
|
||||
{
|
||||
return $this->size?->toBytes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type as string (if available)
|
||||
*/
|
||||
public function getContentType(): ?string
|
||||
{
|
||||
return $this->contentType?->getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last modified timestamp (if available)
|
||||
*/
|
||||
public function getLastModifiedTimestamp(): ?int
|
||||
{
|
||||
return $this->lastModified?->toTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata as array
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getMetadataArray(): array
|
||||
{
|
||||
return $this->metadata->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version ID as string (if available)
|
||||
*/
|
||||
public function getVersionId(): ?string
|
||||
{
|
||||
return $this->versionId?->toString();
|
||||
}
|
||||
}
|
||||
|
||||
81
src/Framework/Storage/StorageInitializer.php
Normal file
81
src/Framework/Storage/StorageInitializer.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage;
|
||||
|
||||
use App\Framework\Config\Environment;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\Encryption\HmacService;
|
||||
use App\Framework\Filesystem\Storage;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
use App\Infrastructure\Storage\MinIoClient;
|
||||
use App\Infrastructure\Storage\S3ObjectStorage;
|
||||
|
||||
/**
|
||||
* Storage System Initializer
|
||||
*
|
||||
* Registriert alle Object Storage Komponenten im DI-Container
|
||||
* entsprechend der Framework-Konventionen.
|
||||
*/
|
||||
final readonly class StorageInitializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function initializeStorage(Container $container): void
|
||||
{
|
||||
$env = $container->get(Environment::class);
|
||||
$driver = $env->getString('STORAGE_DRIVER', 'filesystem');
|
||||
|
||||
// Register HmacService if not already registered
|
||||
if (! $container->has(HmacService::class)) {
|
||||
$container->singleton(HmacService::class, function () {
|
||||
return new HmacService();
|
||||
});
|
||||
}
|
||||
|
||||
// Register MinIoClient (if needed for S3 driver)
|
||||
$container->singleton(MinIoClient::class, function (Container $container) use ($env) {
|
||||
return new MinIoClient(
|
||||
endpoint: $env->getString('MINIO_ENDPOINT', 'http://minio:9000'),
|
||||
accessKey: $env->getString('MINIO_ACCESS_KEY', 'minioadmin'),
|
||||
secretKey: $env->getString('MINIO_SECRET_KEY', 'minioadmin'),
|
||||
region: $env->getString('MINIO_REGION', 'us-east-1'),
|
||||
usePathStyle: $env->getBool('MINIO_USE_PATH_STYLE', true),
|
||||
randomGenerator: $container->get(RandomGenerator::class),
|
||||
hmacService: $container->get(HmacService::class),
|
||||
httpClient: $container->get(CurlHttpClient::class)
|
||||
);
|
||||
});
|
||||
|
||||
// Register S3ObjectStorage
|
||||
$container->singleton(S3ObjectStorage::class, function (Container $container) {
|
||||
return new S3ObjectStorage(
|
||||
client: $container->get(MinIoClient::class)
|
||||
);
|
||||
});
|
||||
|
||||
// Register FilesystemObjectStorage
|
||||
$container->singleton(FilesystemObjectStorage::class, function (Container $container) use ($env) {
|
||||
$storage = $container->get(Storage::class);
|
||||
$basePath = $env->getString('STORAGE_LOCAL_ROOT', '/var/www/html/storage/objects');
|
||||
|
||||
return new FilesystemObjectStorage(
|
||||
storage: $storage,
|
||||
basePath: $basePath
|
||||
);
|
||||
});
|
||||
|
||||
// Register default ObjectStorage based on driver
|
||||
$container->singleton(ObjectStorage::class, function (Container $container) use ($driver) {
|
||||
return match ($driver) {
|
||||
'minio', 's3' => $container->get(S3ObjectStorage::class),
|
||||
'filesystem', 'local' => $container->get(FilesystemObjectStorage::class),
|
||||
'memory', 'inmemory' => $container->get(InMemoryObjectStorage::class),
|
||||
default => throw new \InvalidArgumentException("Unknown storage driver: {$driver}")
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
60
src/Framework/Storage/StreamableObjectStorage.php
Normal file
60
src/Framework/Storage/StreamableObjectStorage.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage;
|
||||
|
||||
/**
|
||||
* Interface für Stream-basierte Object Storage Operationen
|
||||
*
|
||||
* Ermöglicht speichereffizientes Arbeiten mit großen Objekten ohne komplettes Laden in den Speicher.
|
||||
* Ideal für große Dateien, Media-Files und Analytics-Daten.
|
||||
*
|
||||
* Implementiert das Interface Segregation Principle - nur Adapter die Streaming unterstützen
|
||||
* implementieren dieses Interface.
|
||||
*/
|
||||
interface StreamableObjectStorage
|
||||
{
|
||||
/**
|
||||
* Streamt Objekt-Inhalt direkt in einen schreibbaren Stream
|
||||
*
|
||||
* @param string $bucket Bucket-Name
|
||||
* @param string $key Object-Key
|
||||
* @param resource $destination Schreibbarer Stream (z.B. fopen('file.txt', 'w'))
|
||||
* @param array<string, mixed> $opts Optionale Parameter:
|
||||
* - 'bufferSize' => int (default: 8192) Buffer-Größe für Chunked Transfer
|
||||
* @return int Anzahl geschriebener Bytes
|
||||
* @throws \App\Framework\Storage\Exceptions\ObjectNotFoundException Wenn Objekt nicht existiert
|
||||
* @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern
|
||||
*/
|
||||
public function getToStream(string $bucket, string $key, $destination, array $opts = []): int;
|
||||
|
||||
/**
|
||||
* Lädt Objekt-Inhalt von einem lesbaren Stream
|
||||
*
|
||||
* @param string $bucket Bucket-Name
|
||||
* @param string $key Object-Key
|
||||
* @param resource $source Lesbarer Stream (z.B. fopen('file.txt', 'r'))
|
||||
* @param array<string, mixed> $opts Optionale Parameter:
|
||||
* - 'contentType' => string MIME-Type
|
||||
* - 'metadata' => array<string, mixed> Metadata
|
||||
* - 'bufferSize' => int (default: 8192) Buffer-Größe für Chunked Transfer
|
||||
* @return ObjectInfo Metadaten des hochgeladenen Objekts
|
||||
* @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern
|
||||
*/
|
||||
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo;
|
||||
|
||||
/**
|
||||
* Öffnet einen Read-Stream für das Objekt
|
||||
*
|
||||
* WICHTIG: Der zurückgegebene Stream muss vom Caller mit fclose() geschlossen werden!
|
||||
*
|
||||
* @param string $bucket Bucket-Name
|
||||
* @param string $key Object-Key
|
||||
* @return resource Lesbarer Stream
|
||||
* @throws \App\Framework\Storage\Exceptions\ObjectNotFoundException Wenn Objekt nicht existiert
|
||||
* @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern
|
||||
*/
|
||||
public function openReadStream(string $bucket, string $key);
|
||||
}
|
||||
|
||||
122
src/Framework/Storage/ValueObjects/BucketName.php
Normal file
122
src/Framework/Storage/ValueObjects/BucketName.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Bucket Name Value Object
|
||||
*
|
||||
* Type-safe S3 bucket identifier with validation according to AWS S3 bucket naming rules.
|
||||
*/
|
||||
final readonly class BucketName
|
||||
{
|
||||
private const MIN_LENGTH = 3;
|
||||
private const MAX_LENGTH = 63;
|
||||
|
||||
private function __construct(
|
||||
private string $value
|
||||
) {
|
||||
$this->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create BucketName from string
|
||||
*/
|
||||
public static function fromString(string $name): self
|
||||
{
|
||||
return new self($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bucket name as string
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two bucket names are equal
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate bucket name according to S3 rules
|
||||
*/
|
||||
private function validate(): void
|
||||
{
|
||||
$length = strlen($this->value);
|
||||
|
||||
// Length check
|
||||
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
|
||||
throw new InvalidArgumentException(
|
||||
"Bucket name must be between " . self::MIN_LENGTH . " and " . self::MAX_LENGTH . " characters (got {$length})"
|
||||
);
|
||||
}
|
||||
|
||||
// Must start and end with alphanumeric character
|
||||
if (! ctype_alnum($this->value[0])) {
|
||||
throw new InvalidArgumentException('Bucket name must start with a letter or number');
|
||||
}
|
||||
|
||||
if (! ctype_alnum($this->value[$length - 1])) {
|
||||
throw new InvalidArgumentException('Bucket name must end with a letter or number');
|
||||
}
|
||||
|
||||
// Only lowercase letters, numbers, dots, and hyphens allowed
|
||||
if (! preg_match('/^[a-z0-9.-]+$/', $this->value)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Bucket name can only contain lowercase letters, numbers, dots, and hyphens'
|
||||
);
|
||||
}
|
||||
|
||||
// No consecutive dots
|
||||
if (str_contains($this->value, '..')) {
|
||||
throw new InvalidArgumentException('Bucket name cannot contain consecutive dots');
|
||||
}
|
||||
|
||||
// Cannot be formatted as an IP address (e.g., 192.168.1.1)
|
||||
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $this->value)) {
|
||||
throw new InvalidArgumentException('Bucket name cannot be formatted as an IP address');
|
||||
}
|
||||
|
||||
// Cannot start with "xn--" (punycode prefix)
|
||||
if (str_starts_with($this->value, 'xn--')) {
|
||||
throw new InvalidArgumentException('Bucket name cannot start with "xn--"');
|
||||
}
|
||||
|
||||
// Cannot end with "-s3alias" (S3 alias suffix)
|
||||
if (str_ends_with($this->value, '-s3alias')) {
|
||||
throw new InvalidArgumentException('Bucket name cannot end with "-s3alias"');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static validation method (for pre-validation without creating object)
|
||||
*/
|
||||
public static function isValid(string $name): bool
|
||||
{
|
||||
try {
|
||||
new self($name);
|
||||
|
||||
return true;
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
162
src/Framework/Storage/ValueObjects/ObjectKey.php
Normal file
162
src/Framework/Storage/ValueObjects/ObjectKey.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Object Key Value Object
|
||||
*
|
||||
* Type-safe S3 object key identifier with validation according to AWS S3 key naming rules.
|
||||
* Supports path-like structures (e.g., "folder/subfolder/file.txt").
|
||||
*/
|
||||
final readonly class ObjectKey
|
||||
{
|
||||
private const MAX_LENGTH = 1024;
|
||||
|
||||
private function __construct(
|
||||
private string $value
|
||||
) {
|
||||
$this->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ObjectKey from string
|
||||
*/
|
||||
public static function fromString(string $key): self
|
||||
{
|
||||
return new self($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object key as string
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two object keys are equal
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory path (without filename)
|
||||
*
|
||||
* Example: "folder/subfolder/file.txt" -> "folder/subfolder"
|
||||
*/
|
||||
public function getDirectory(): ?string
|
||||
{
|
||||
$lastSlash = strrpos($this->value, '/');
|
||||
if ($lastSlash === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr($this->value, 0, $lastSlash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filename (last path segment)
|
||||
*
|
||||
* Example: "folder/subfolder/file.txt" -> "file.txt"
|
||||
*/
|
||||
public function getFilename(): ?string
|
||||
{
|
||||
$lastSlash = strrpos($this->value, '/');
|
||||
if ($lastSlash === false) {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
return substr($this->value, $lastSlash + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension (without dot)
|
||||
*
|
||||
* Example: "file.txt" -> "txt"
|
||||
*/
|
||||
public function getExtension(): ?string
|
||||
{
|
||||
$filename = $this->getFilename();
|
||||
if ($filename === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lastDot = strrpos($filename, '.');
|
||||
if ($lastDot === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr($filename, $lastDot + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key has a directory path
|
||||
*/
|
||||
public function hasDirectory(): bool
|
||||
{
|
||||
return str_contains($this->value, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate object key according to S3 rules
|
||||
*/
|
||||
private function validate(): void
|
||||
{
|
||||
$length = strlen($this->value);
|
||||
|
||||
// Length check
|
||||
if ($length > self::MAX_LENGTH) {
|
||||
throw new InvalidArgumentException(
|
||||
"Object key cannot exceed " . self::MAX_LENGTH . " characters (got {$length})"
|
||||
);
|
||||
}
|
||||
|
||||
// Empty keys are allowed (for root-level objects)
|
||||
if ($length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for control characters (0x00-0x1F, 0x7F)
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $this->value)) {
|
||||
throw new InvalidArgumentException('Object key cannot contain control characters');
|
||||
}
|
||||
|
||||
// Validate UTF-8 encoding
|
||||
if (! mb_check_encoding($this->value, 'UTF-8')) {
|
||||
throw new InvalidArgumentException('Object key must be valid UTF-8');
|
||||
}
|
||||
|
||||
// S3 allows most characters, but we should avoid some problematic ones
|
||||
// Note: S3 actually allows most characters, but we'll be conservative
|
||||
}
|
||||
|
||||
/**
|
||||
* Static validation method (for pre-validation without creating object)
|
||||
*/
|
||||
public static function isValid(string $key): bool
|
||||
{
|
||||
try {
|
||||
new self($key);
|
||||
|
||||
return true;
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
192
src/Framework/Storage/ValueObjects/ObjectMetadata.php
Normal file
192
src/Framework/Storage/ValueObjects/ObjectMetadata.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Object Metadata Value Object
|
||||
*
|
||||
* Type-safe metadata container for storage objects.
|
||||
* Provides immutable access to key-value metadata pairs.
|
||||
*/
|
||||
final readonly class ObjectMetadata
|
||||
{
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $data;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->validate($data);
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ObjectMetadata from array
|
||||
*
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public static function fromArray(array $metadata): self
|
||||
{
|
||||
return new self($metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty ObjectMetadata
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata value by key
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->data[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if metadata key exists
|
||||
*/
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return isset($this->data[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metadata as array
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new instance with additional/updated metadata
|
||||
*/
|
||||
public function with(string $key, mixed $value): self
|
||||
{
|
||||
$newData = $this->data;
|
||||
$newData[$key] = $value;
|
||||
|
||||
return new self($newData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new instance without specified key
|
||||
*/
|
||||
public function without(string $key): self
|
||||
{
|
||||
$newData = $this->data;
|
||||
unset($newData[$key]);
|
||||
|
||||
return new self($newData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge with another ObjectMetadata
|
||||
*/
|
||||
public function merge(self $other): self
|
||||
{
|
||||
return new self(array_merge($this->data, $other->data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if metadata is empty
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of metadata entries
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function keys(): array
|
||||
{
|
||||
return array_keys($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public function values(): array
|
||||
{
|
||||
return array_values($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate metadata structure
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function validate(array $data): void
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
// Key validation
|
||||
if (! is_string($key)) {
|
||||
throw new InvalidArgumentException('Metadata keys must be strings');
|
||||
}
|
||||
|
||||
if (empty($key)) {
|
||||
throw new InvalidArgumentException('Metadata keys cannot be empty');
|
||||
}
|
||||
|
||||
if (strlen($key) > 255) {
|
||||
throw new InvalidArgumentException('Metadata keys cannot exceed 255 characters');
|
||||
}
|
||||
|
||||
// Value validation - only allow serializable types
|
||||
if (! $this->isValidValue($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Metadata value for key '{$key}' must be serializable (string, int, float, bool, null, or array of these)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is valid for metadata
|
||||
*/
|
||||
private function isValidValue(mixed $value): bool
|
||||
{
|
||||
if (is_scalar($value) || $value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $item) {
|
||||
if (! $this->isValidValue($item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
114
src/Framework/Storage/ValueObjects/VersionId.php
Normal file
114
src/Framework/Storage/ValueObjects/VersionId.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Storage\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Version ID Value Object
|
||||
*
|
||||
* Type-safe S3 object version identifier.
|
||||
* Used for S3 object versioning support.
|
||||
*/
|
||||
final readonly class VersionId
|
||||
{
|
||||
private const MAX_LENGTH = 255;
|
||||
private const NULL_VERSION = 'null';
|
||||
|
||||
private function __construct(
|
||||
private string $value
|
||||
) {
|
||||
$this->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create VersionId from string
|
||||
*/
|
||||
public static function fromString(string $versionId): self
|
||||
{
|
||||
return new self($versionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create null version (represents current version or no versioning)
|
||||
*/
|
||||
public static function null(): self
|
||||
{
|
||||
return new self(self::NULL_VERSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version ID as string
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two version IDs are equal
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a null version
|
||||
*/
|
||||
public function isNullVersion(): bool
|
||||
{
|
||||
return $this->value === self::NULL_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate version ID
|
||||
*/
|
||||
private function validate(): void
|
||||
{
|
||||
$length = strlen($this->value);
|
||||
|
||||
// Cannot be empty
|
||||
if ($length === 0) {
|
||||
throw new InvalidArgumentException('Version ID cannot be empty');
|
||||
}
|
||||
|
||||
// Length check
|
||||
if ($length > self::MAX_LENGTH) {
|
||||
throw new InvalidArgumentException(
|
||||
"Version ID cannot exceed " . self::MAX_LENGTH . " characters (got {$length})"
|
||||
);
|
||||
}
|
||||
|
||||
// S3 version IDs are typically alphanumeric with some special characters
|
||||
// We'll be permissive but validate basic constraints
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $this->value)) {
|
||||
throw new InvalidArgumentException('Version ID cannot contain control characters');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static validation method (for pre-validation without creating object)
|
||||
*/
|
||||
public static function isValid(string $versionId): bool
|
||||
{
|
||||
try {
|
||||
new self($versionId);
|
||||
|
||||
return true;
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,15 @@ final readonly class DiscoveryTokenizer
|
||||
$name = $this->findNextIdentifier($tokens, $token);
|
||||
|
||||
if ($name) {
|
||||
// Validate: Only extract classes that are declared in the current namespace context
|
||||
// The currentNamespace should match the file's namespace declaration
|
||||
$currentNamespace = $context->currentNamespace ?? null;
|
||||
|
||||
// For class FQN, only use namespace + name (not currentClass)
|
||||
$fqn = $context->currentNamespace ? $context->currentNamespace . '\\' . $name : $name;
|
||||
// IMPORTANT: Only use currentNamespace if it's actually set (from namespace declaration)
|
||||
// This ensures we only extract classes that are declared in this file's namespace
|
||||
$fqn = $currentNamespace ? $currentNamespace . '\\' . $name : $name;
|
||||
|
||||
$classes[] = [
|
||||
'type' => match($token->id) {
|
||||
T_CLASS => 'class',
|
||||
@@ -45,7 +52,7 @@ final readonly class DiscoveryTokenizer
|
||||
default => 'unknown'
|
||||
},
|
||||
'name' => $name,
|
||||
'namespace' => $context->currentNamespace,
|
||||
'namespace' => $currentNamespace,
|
||||
'fqn' => $fqn,
|
||||
'line' => $token->line,
|
||||
];
|
||||
@@ -140,20 +147,58 @@ final readonly class DiscoveryTokenizer
|
||||
}
|
||||
|
||||
/**
|
||||
* Find next identifier after a token
|
||||
* Find next identifier after a token (class/interface/trait/enum name)
|
||||
* Stops at structural elements to avoid extracting method names
|
||||
*/
|
||||
private function findNextIdentifier(TokenCollection $tokens, $startToken): ?string
|
||||
{
|
||||
$found = false;
|
||||
foreach ($tokens as $token) {
|
||||
if ($found && $token->id === T_STRING) {
|
||||
return $token->value;
|
||||
}
|
||||
$tokensArray = $tokens->toArray();
|
||||
$startIndex = null;
|
||||
|
||||
// Find the start token index
|
||||
foreach ($tokensArray as $index => $token) {
|
||||
if ($token === $startToken) {
|
||||
$found = true;
|
||||
$startIndex = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ($startIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tokens that are allowed before the class name (modifiers)
|
||||
$allowedModifiers = [T_FINAL, T_ABSTRACT, T_READONLY];
|
||||
|
||||
// Tokens that stop the search (structural boundaries)
|
||||
$stopTokens = [
|
||||
'{', ';', // Class body start or statement end
|
||||
T_EXTENDS, T_IMPLEMENTS, // Inheritance keywords
|
||||
T_FUNCTION, T_FN, // Function/method declarations
|
||||
T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM, // Other class-like declarations
|
||||
];
|
||||
|
||||
// Iterate through tokens after the start token
|
||||
for ($i = $startIndex + 1; $i < count($tokensArray); $i++) {
|
||||
$token = $tokensArray[$i];
|
||||
|
||||
// Stop at structural boundaries
|
||||
if ($token->value === '{' || $token->value === ';' ||
|
||||
$token->is($stopTokens)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip whitespace and allowed modifiers
|
||||
if ($token->id === T_WHITESPACE || $token->is($allowedModifiers)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found the identifier (class name)
|
||||
if ($token->id === T_STRING) {
|
||||
return $token->value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user