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;
|
||||
}
|
||||
|
||||
|
||||
211
src/Infrastructure/Api/Gitea/ActionService.php
Normal file
211
src/Infrastructure/Api/Gitea/ActionService.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
use App\Infrastructure\Api\Gitea\ValueObjects\{
|
||||
WorkflowList,
|
||||
WorkflowRunsList,
|
||||
WorkflowRun,
|
||||
Workflow,
|
||||
RunId
|
||||
};
|
||||
|
||||
final readonly class ActionService
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiClient $apiClient
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Listet alle Workflows eines Repositories
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @return array Liste der Workflows
|
||||
*/
|
||||
public function listWorkflows(string $owner, string $repo): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::GET,
|
||||
"repos/{$owner}/{$repo}/actions/workflows"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listet alle Workflow Runs eines Repositories
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param array $options Optionale Parameter (status, workflow_id, page, limit, etc.)
|
||||
* @return array Liste der Runs
|
||||
*/
|
||||
public function listRuns(string $owner, string $repo, array $options = []): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::GET,
|
||||
"repos/{$owner}/{$repo}/actions/runs",
|
||||
[],
|
||||
$options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft Details eines Workflow Runs ab
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param int $runId Run ID
|
||||
* @return array Run-Details
|
||||
*/
|
||||
public function getRun(string $owner, string $repo, int $runId): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::GET,
|
||||
"repos/{$owner}/{$repo}/actions/runs/{$runId}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggert einen Workflow manuell
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param string $workflowId Workflow ID oder Dateiname (z.B. "ci.yml")
|
||||
* @param array $inputs Optionale Inputs für den Workflow
|
||||
* @param string|null $ref Optional: Branch/Tag/Commit SHA (Standard: default branch)
|
||||
* @return array Response (normalerweise 204 No Content)
|
||||
*/
|
||||
public function triggerWorkflow(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $workflowId,
|
||||
array $inputs = [],
|
||||
?string $ref = null
|
||||
): array {
|
||||
$data = [];
|
||||
if (! empty($inputs)) {
|
||||
$data['inputs'] = $inputs;
|
||||
}
|
||||
if ($ref !== null) {
|
||||
$data['ref'] = $ref;
|
||||
}
|
||||
|
||||
return $this->apiClient->request(
|
||||
Method::POST,
|
||||
"repos/{$owner}/{$repo}/actions/workflows/{$workflowId}/dispatches",
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bricht einen laufenden Workflow Run ab
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param int $runId Run ID
|
||||
* @return array Response
|
||||
*/
|
||||
public function cancelRun(string $owner, string $repo, int $runId): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::POST,
|
||||
"repos/{$owner}/{$repo}/actions/runs/{$runId}/cancel"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft die Logs eines Workflow Runs ab
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param int $runId Run ID
|
||||
* @return string Logs als Text (oder Array wenn JSON)
|
||||
*/
|
||||
public function getLogs(string $owner, string $repo, int $runId): string
|
||||
{
|
||||
$response = $this->apiClient->sendRawRequest(
|
||||
Method::GET,
|
||||
"repos/{$owner}/{$repo}/actions/runs/{$runId}/logs"
|
||||
);
|
||||
|
||||
return $response->body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft den Status eines Workflow Runs ab (Helper-Methode)
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param int $runId Run ID
|
||||
* @return string Status (z.B. "success", "failure", "cancelled", "running", "waiting")
|
||||
*/
|
||||
public function getRunStatus(string $owner, string $repo, int $runId): string
|
||||
{
|
||||
$run = $this->getRun($owner, $repo, $runId);
|
||||
|
||||
return $run['status'] ?? 'unknown';
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Typed Value Object Methods (Parallel Implementation)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Listet alle Workflows eines Repositories (typed)
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @return WorkflowList Type-safe Workflow Liste
|
||||
*/
|
||||
public function listWorkflowsTyped(string $owner, string $repo): WorkflowList
|
||||
{
|
||||
$data = $this->listWorkflows($owner, $repo);
|
||||
return WorkflowList::fromApiResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listet alle Workflow Runs eines Repositories (typed)
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param array $options Optionale Parameter (status, workflow_id, page, limit, etc.)
|
||||
* @return WorkflowRunsList Type-safe Workflow Runs Liste
|
||||
*/
|
||||
public function listRunsTyped(string $owner, string $repo, array $options = []): WorkflowRunsList
|
||||
{
|
||||
$data = $this->listRuns($owner, $repo, $options);
|
||||
return WorkflowRunsList::fromApiResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft Details eines Workflow Runs ab (typed)
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param int $runId Run ID
|
||||
* @return WorkflowRun Type-safe Workflow Run
|
||||
*/
|
||||
public function getRunTyped(string $owner, string $repo, int $runId): WorkflowRun
|
||||
{
|
||||
$data = $this->getRun($owner, $repo, $runId);
|
||||
return WorkflowRun::fromApiResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft Details eines Workflow Runs ab via RunId (typed)
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param RunId $runId Run ID Value Object
|
||||
* @return WorkflowRun Type-safe Workflow Run
|
||||
*/
|
||||
public function getRunByIdTyped(string $owner, string $repo, RunId $runId): WorkflowRun
|
||||
{
|
||||
return $this->getRunTyped($owner, $repo, $runId->value);
|
||||
}
|
||||
}
|
||||
|
||||
158
src/Infrastructure/Api/Gitea/GiteaApiClient.php
Normal file
158
src/Infrastructure/Api/Gitea/GiteaApiClient.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Api\ApiException;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\HttpClient\AuthConfig;
|
||||
use App\Framework\HttpClient\ClientOptions;
|
||||
use App\Framework\HttpClient\ClientRequest;
|
||||
use App\Framework\HttpClient\ClientResponse;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
final readonly class GiteaApiClient
|
||||
{
|
||||
private ClientOptions $defaultOptions;
|
||||
|
||||
public function __construct(
|
||||
private GiteaConfig $config,
|
||||
private HttpClient $httpClient
|
||||
) {
|
||||
$authConfig = $this->buildAuthConfig();
|
||||
$this->defaultOptions = new ClientOptions(
|
||||
timeout: (int) $this->config->timeout,
|
||||
auth: $authConfig
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet eine API-Anfrage und gibt JSON-Daten zurück
|
||||
*/
|
||||
public function request(
|
||||
Method $method,
|
||||
string $endpoint,
|
||||
array $data = [],
|
||||
array $queryParams = []
|
||||
): array {
|
||||
$response = $this->sendRawRequest($method, $endpoint, $data, $queryParams);
|
||||
|
||||
return $this->handleResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet eine API-Anfrage und gibt raw Response zurück
|
||||
*/
|
||||
public function sendRawRequest(
|
||||
Method $method,
|
||||
string $endpoint,
|
||||
array $data = [],
|
||||
array $queryParams = []
|
||||
): ClientResponse {
|
||||
$baseUrl = rtrim($this->config->baseUrl, '/');
|
||||
$url = $baseUrl . '/api/v1/' . ltrim($endpoint, '/');
|
||||
|
||||
$options = $this->defaultOptions;
|
||||
if (! empty($queryParams)) {
|
||||
$options = $options->with(['query' => $queryParams]);
|
||||
}
|
||||
|
||||
if (in_array($method, [Method::GET, Method::DELETE]) && ! empty($data)) {
|
||||
$options = $options->with(['query' => array_merge($options->query, $data)]);
|
||||
$data = [];
|
||||
}
|
||||
|
||||
$request = empty($data)
|
||||
? new ClientRequest($method, $url, options: $options)
|
||||
: ClientRequest::json($method, $url, $data, $options);
|
||||
|
||||
$response = $this->httpClient->send($request);
|
||||
|
||||
if (! $response->isSuccessful()) {
|
||||
$this->throwApiException($response);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt API-Response
|
||||
*/
|
||||
private function handleResponse(ClientResponse $response): array
|
||||
{
|
||||
if (! $response->isJson()) {
|
||||
throw new ApiException(
|
||||
'Expected JSON response, got: ' . $response->getContentType(),
|
||||
0,
|
||||
$response
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return $response->json();
|
||||
} catch (\Exception $e) {
|
||||
throw new ApiException(
|
||||
'Invalid JSON response: ' . $e->getMessage(),
|
||||
0,
|
||||
$response
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wirft API-Exception
|
||||
*/
|
||||
private function throwApiException(ClientResponse $response): never
|
||||
{
|
||||
$data = [];
|
||||
|
||||
if ($response->isJson()) {
|
||||
try {
|
||||
$data = $response->json();
|
||||
} catch (\Exception) {
|
||||
// JSON parsing failed
|
||||
}
|
||||
}
|
||||
|
||||
$message = $this->formatErrorMessage($data, $response);
|
||||
|
||||
throw new ApiException($message, $response->status->value, $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Fehlermeldung
|
||||
*/
|
||||
private function formatErrorMessage(array $responseData, ClientResponse $response): string
|
||||
{
|
||||
if (isset($responseData['message'])) {
|
||||
return 'Gitea API Error: ' . $responseData['message'];
|
||||
}
|
||||
|
||||
if (isset($responseData['error'])) {
|
||||
return 'Gitea API Error: ' . $responseData['error'];
|
||||
}
|
||||
|
||||
return "Gitea API Error (HTTP {$response->status->value}): " .
|
||||
substr($response->body, 0, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt AuthConfig basierend auf GiteaConfig
|
||||
*/
|
||||
private function buildAuthConfig(): AuthConfig
|
||||
{
|
||||
if ($this->config->token !== null) {
|
||||
return AuthConfig::bearer($this->config->token);
|
||||
}
|
||||
|
||||
if ($this->config->username !== null && $this->config->password !== null) {
|
||||
return AuthConfig::basic($this->config->username, $this->config->password);
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException(
|
||||
'Either token or username+password must be provided for Gitea authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
31
src/Infrastructure/Api/Gitea/GiteaClient.php
Normal file
31
src/Infrastructure/Api/Gitea/GiteaClient.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
final readonly class GiteaClient
|
||||
{
|
||||
public RepositoryService $repositories;
|
||||
|
||||
public UserService $users;
|
||||
|
||||
public IssueService $issues;
|
||||
|
||||
public ActionService $actions;
|
||||
|
||||
public function __construct(
|
||||
GiteaConfig $config,
|
||||
HttpClient $httpClient
|
||||
) {
|
||||
$apiClient = new GiteaApiClient($config, $httpClient);
|
||||
|
||||
$this->repositories = new RepositoryService($apiClient);
|
||||
$this->users = new UserService($apiClient);
|
||||
$this->issues = new IssueService($apiClient);
|
||||
$this->actions = new ActionService($apiClient);
|
||||
}
|
||||
}
|
||||
|
||||
38
src/Infrastructure/Api/Gitea/GiteaClientInitializer.php
Normal file
38
src/Infrastructure/Api/Gitea/GiteaClientInitializer.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Config\Environment;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
final readonly class GiteaClientInitializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function __invoke(Container $container): GiteaClient
|
||||
{
|
||||
$env = $container->get(Environment::class);
|
||||
$httpClient = $container->get(HttpClient::class) ?? new CurlHttpClient();
|
||||
|
||||
$baseUrl = $env->get('GITEA_URL', 'https://git.michaelschiemer.de');
|
||||
$token = $env->get('GITEA_TOKEN');
|
||||
$username = $env->get('GITEA_USERNAME');
|
||||
$password = $env->get('GITEA_PASSWORD');
|
||||
$timeout = (float) $env->get('GITEA_TIMEOUT', '30.0');
|
||||
|
||||
$config = new GiteaConfig(
|
||||
baseUrl: $baseUrl,
|
||||
token: $token,
|
||||
username: $username,
|
||||
password: $password,
|
||||
timeout: $timeout
|
||||
);
|
||||
|
||||
return new GiteaClient($config, $httpClient);
|
||||
}
|
||||
}
|
||||
|
||||
23
src/Infrastructure/Api/Gitea/GiteaConfig.php
Normal file
23
src/Infrastructure/Api/Gitea/GiteaConfig.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
final readonly class GiteaConfig
|
||||
{
|
||||
public function __construct(
|
||||
public string $baseUrl,
|
||||
public ?string $token = null,
|
||||
public ?string $username = null,
|
||||
public ?string $password = null,
|
||||
public float $timeout = 30.0
|
||||
) {
|
||||
if ($this->token === null && ($this->username === null || $this->password === null)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Either token or username+password must be provided for Gitea authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
98
src/Infrastructure/Api/Gitea/IssueService.php
Normal file
98
src/Infrastructure/Api/Gitea/IssueService.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
|
||||
final readonly class IssueService
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiClient $apiClient
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Listet alle Issues eines Repositories
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param array $options Optionale Parameter (state, labels, page, limit, etc.)
|
||||
* @return array Liste der Issues
|
||||
*/
|
||||
public function list(string $owner, string $repo, array $options = []): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::GET,
|
||||
"repos/{$owner}/{$repo}/issues",
|
||||
[],
|
||||
$options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft ein Issue ab
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param int $index Issue Index
|
||||
* @return array Issue-Daten
|
||||
*/
|
||||
public function get(string $owner, string $repo, int $index): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::GET,
|
||||
"repos/{$owner}/{$repo}/issues/{$index}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein neues Issue
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param array $data Issue-Daten (title, body, assignees, labels, etc.)
|
||||
* @return array Erstelltes Issue
|
||||
*/
|
||||
public function create(string $owner, string $repo, array $data): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::POST,
|
||||
"repos/{$owner}/{$repo}/issues",
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert ein Issue
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param int $index Issue Index
|
||||
* @param array $data Zu aktualisierende Daten
|
||||
* @return array Aktualisiertes Issue
|
||||
*/
|
||||
public function update(string $owner, string $repo, int $index, array $data): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::PATCH,
|
||||
"repos/{$owner}/{$repo}/issues/{$index}",
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schließt ein Issue
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param int $index Issue Index
|
||||
* @return array Aktualisiertes Issue
|
||||
*/
|
||||
public function close(string $owner, string $repo, int $index): array
|
||||
{
|
||||
return $this->update($owner, $repo, $index, ['state' => 'closed']);
|
||||
}
|
||||
}
|
||||
|
||||
501
src/Infrastructure/Api/Gitea/README.md
Normal file
501
src/Infrastructure/Api/Gitea/README.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# Gitea API Client
|
||||
|
||||
## Übersicht
|
||||
|
||||
Dieser Client bietet eine strukturierte Schnittstelle für die Kommunikation mit der Gitea API v1. Er unterstützt Basis-Operationen für Repositories, User und Issues.
|
||||
|
||||
## Architektur
|
||||
|
||||
Der Client folgt dem Service-Layer-Pattern:
|
||||
|
||||
- **GiteaApiClient**: Low-level API Client für HTTP-Kommunikation
|
||||
- **RepositoryService**: Repository-Verwaltung
|
||||
- **UserService**: User-Verwaltung
|
||||
- **IssueService**: Issue-Verwaltung
|
||||
- **ActionService**: Workflow/Action Management für Testing und Trigger
|
||||
- **GiteaClient**: Facade, die alle Services bereitstellt
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```env
|
||||
GITEA_URL=https://git.michaelschiemer.de
|
||||
GITEA_TOKEN=your_access_token
|
||||
# ODER
|
||||
GITEA_USERNAME=your_username
|
||||
GITEA_PASSWORD=your_password
|
||||
GITEA_TIMEOUT=30.0
|
||||
```
|
||||
|
||||
### Manuelle Konfiguration
|
||||
|
||||
```php
|
||||
use App\Infrastructure\Api\Gitea\GiteaClient;
|
||||
use App\Infrastructure\Api\Gitea\GiteaConfig;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
|
||||
// Mit Token
|
||||
$config = new GiteaConfig(
|
||||
baseUrl: 'https://git.michaelschiemer.de',
|
||||
token: 'your_access_token'
|
||||
);
|
||||
|
||||
// Mit Username/Password
|
||||
$config = new GiteaConfig(
|
||||
baseUrl: 'https://git.michaelschiemer.de',
|
||||
username: 'your_username',
|
||||
password: 'your_password'
|
||||
);
|
||||
|
||||
$client = new GiteaClient($config, new CurlHttpClient());
|
||||
```
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
```php
|
||||
use App\Infrastructure\Api\Gitea\GiteaClient;
|
||||
|
||||
// Der GiteaClientInitializer lädt automatisch die Konfiguration aus Environment
|
||||
$client = $container->get(GiteaClient::class);
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```php
|
||||
use App\Infrastructure\Api\Gitea\GiteaClient;
|
||||
|
||||
$client = $container->get(GiteaClient::class);
|
||||
|
||||
// Repository-Operationen
|
||||
$repos = $client->repositories->list();
|
||||
$repo = $client->repositories->get('owner', 'repo-name');
|
||||
|
||||
// User-Operationen
|
||||
$currentUser = $client->users->getCurrent();
|
||||
$user = $client->users->get('username');
|
||||
|
||||
// Issue-Operationen
|
||||
$issues = $client->issues->list('owner', 'repo-name');
|
||||
$issue = $client->issues->get('owner', 'repo-name', 1);
|
||||
|
||||
// Action/Workflow-Operationen
|
||||
$workflows = $client->actions->listWorkflows('owner', 'repo-name');
|
||||
$runs = $client->actions->listRuns('owner', 'repo-name');
|
||||
$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml');
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### RepositoryService
|
||||
|
||||
#### list()
|
||||
|
||||
Listet alle Repositories des authentifizierten Users.
|
||||
|
||||
```php
|
||||
$repos = $client->repositories->list();
|
||||
$repos = $client->repositories->list(['page' => 1, 'limit' => 50]);
|
||||
```
|
||||
|
||||
#### get(string $owner, string $repo)
|
||||
|
||||
Ruft ein Repository ab.
|
||||
|
||||
```php
|
||||
$repo = $client->repositories->get('owner', 'repo-name');
|
||||
```
|
||||
|
||||
#### create(array $data)
|
||||
|
||||
Erstellt ein neues Repository.
|
||||
|
||||
```php
|
||||
$repo = $client->repositories->create([
|
||||
'name' => 'my-repo',
|
||||
'description' => 'My repository description',
|
||||
'private' => true,
|
||||
'auto_init' => false,
|
||||
'default_branch' => 'main'
|
||||
]);
|
||||
```
|
||||
|
||||
#### update(string $owner, string $repo, array $data)
|
||||
|
||||
Aktualisiert ein Repository.
|
||||
|
||||
```php
|
||||
$repo = $client->repositories->update('owner', 'repo-name', [
|
||||
'description' => 'Updated description',
|
||||
'private' => false
|
||||
]);
|
||||
```
|
||||
|
||||
#### delete(string $owner, string $repo)
|
||||
|
||||
Löscht ein Repository.
|
||||
|
||||
```php
|
||||
$client->repositories->delete('owner', 'repo-name');
|
||||
```
|
||||
|
||||
### UserService
|
||||
|
||||
#### getCurrent()
|
||||
|
||||
Ruft den aktuellen authentifizierten User ab.
|
||||
|
||||
```php
|
||||
$user = $client->users->getCurrent();
|
||||
```
|
||||
|
||||
#### get(string $username)
|
||||
|
||||
Ruft einen User anhand des Usernames ab.
|
||||
|
||||
```php
|
||||
$user = $client->users->get('username');
|
||||
```
|
||||
|
||||
#### list(array $options = [])
|
||||
|
||||
Sucht nach Usern.
|
||||
|
||||
```php
|
||||
$users = $client->users->list(['q' => 'search-term', 'page' => 1, 'limit' => 50]);
|
||||
```
|
||||
|
||||
### IssueService
|
||||
|
||||
#### list(string $owner, string $repo, array $options = [])
|
||||
|
||||
Listet alle Issues eines Repositories.
|
||||
|
||||
```php
|
||||
$issues = $client->issues->list('owner', 'repo-name');
|
||||
$issues = $client->issues->list('owner', 'repo-name', [
|
||||
'state' => 'open',
|
||||
'labels' => 'bug',
|
||||
'page' => 1,
|
||||
'limit' => 50
|
||||
]);
|
||||
```
|
||||
|
||||
#### get(string $owner, string $repo, int $index)
|
||||
|
||||
Ruft ein Issue ab.
|
||||
|
||||
```php
|
||||
$issue = $client->issues->get('owner', 'repo-name', 1);
|
||||
```
|
||||
|
||||
#### create(string $owner, string $repo, array $data)
|
||||
|
||||
Erstellt ein neues Issue.
|
||||
|
||||
```php
|
||||
$issue = $client->issues->create('owner', 'repo-name', [
|
||||
'title' => 'Bug Report',
|
||||
'body' => 'Issue description',
|
||||
'assignees' => ['username'],
|
||||
'labels' => [1, 2, 3]
|
||||
]);
|
||||
```
|
||||
|
||||
#### update(string $owner, string $repo, int $index, array $data)
|
||||
|
||||
Aktualisiert ein Issue.
|
||||
|
||||
```php
|
||||
$issue = $client->issues->update('owner', 'repo-name', 1, [
|
||||
'title' => 'Updated title',
|
||||
'body' => 'Updated description',
|
||||
'state' => 'open'
|
||||
]);
|
||||
```
|
||||
|
||||
#### close(string $owner, string $repo, int $index)
|
||||
|
||||
Schließt ein Issue.
|
||||
|
||||
```php
|
||||
$issue = $client->issues->close('owner', 'repo-name', 1);
|
||||
```
|
||||
|
||||
### ActionService
|
||||
|
||||
#### listWorkflows(string $owner, string $repo)
|
||||
|
||||
Listet alle Workflows eines Repositories.
|
||||
|
||||
```php
|
||||
$workflows = $client->actions->listWorkflows('owner', 'repo-name');
|
||||
```
|
||||
|
||||
#### listRuns(string $owner, string $repo, array $options = [])
|
||||
|
||||
Listet alle Workflow Runs eines Repositories.
|
||||
|
||||
```php
|
||||
$runs = $client->actions->listRuns('owner', 'repo-name');
|
||||
$runs = $client->actions->listRuns('owner', 'repo-name', [
|
||||
'status' => 'success',
|
||||
'workflow_id' => 1,
|
||||
'page' => 1,
|
||||
'limit' => 50
|
||||
]);
|
||||
```
|
||||
|
||||
#### getRun(string $owner, string $repo, int $runId)
|
||||
|
||||
Ruft Details eines Workflow Runs ab.
|
||||
|
||||
```php
|
||||
$run = $client->actions->getRun('owner', 'repo-name', 123);
|
||||
```
|
||||
|
||||
#### triggerWorkflow(string $owner, string $repo, string $workflowId, array $inputs = [], ?string $ref = null)
|
||||
|
||||
Triggert einen Workflow manuell.
|
||||
|
||||
```php
|
||||
// Workflow ohne Inputs triggern
|
||||
$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml');
|
||||
|
||||
// Workflow mit Inputs triggern
|
||||
$client->actions->triggerWorkflow('owner', 'repo-name', 'deploy.yml', [
|
||||
'environment' => 'production',
|
||||
'skip_tests' => false
|
||||
]);
|
||||
|
||||
// Workflow auf spezifischem Branch triggern
|
||||
$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml', [], 'develop');
|
||||
```
|
||||
|
||||
#### cancelRun(string $owner, string $repo, int $runId)
|
||||
|
||||
Bricht einen laufenden Workflow Run ab.
|
||||
|
||||
```php
|
||||
$client->actions->cancelRun('owner', 'repo-name', 123);
|
||||
```
|
||||
|
||||
#### getLogs(string $owner, string $repo, int $runId)
|
||||
|
||||
Ruft die Logs eines Workflow Runs ab.
|
||||
|
||||
```php
|
||||
$logs = $client->actions->getLogs('owner', 'repo-name', 123);
|
||||
echo $logs; // Logs als Text
|
||||
```
|
||||
|
||||
#### getRunStatus(string $owner, string $repo, int $runId)
|
||||
|
||||
Ruft den Status eines Workflow Runs ab (Helper-Methode).
|
||||
|
||||
```php
|
||||
$status = $client->actions->getRunStatus('owner', 'repo-name', 123);
|
||||
// Mögliche Werte: "success", "failure", "cancelled", "running", "waiting"
|
||||
```
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
Der Client unterstützt zwei Authentifizierungsmethoden:
|
||||
|
||||
### Token-Authentifizierung (empfohlen)
|
||||
|
||||
```php
|
||||
$config = new GiteaConfig(
|
||||
baseUrl: 'https://git.michaelschiemer.de',
|
||||
token: 'your_access_token'
|
||||
);
|
||||
```
|
||||
|
||||
### Basic Authentication
|
||||
|
||||
```php
|
||||
$config = new GiteaConfig(
|
||||
baseUrl: 'https://git.michaelschiemer.de',
|
||||
username: 'your_username',
|
||||
password: 'your_password'
|
||||
);
|
||||
```
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
Alle API-Clients werfen eine standardisierte `ApiException` bei Fehlern:
|
||||
|
||||
```php
|
||||
use App\Framework\Api\ApiException;
|
||||
|
||||
try {
|
||||
$repo = $client->repositories->get('owner', 'repo-name');
|
||||
} catch (ApiException $e) {
|
||||
echo "Error: " . $e->getMessage();
|
||||
echo "Status Code: " . $e->getCode();
|
||||
// Zugriff auf Response-Daten
|
||||
$responseData = $e->getResponseData();
|
||||
}
|
||||
```
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
Der Client nutzt die folgenden Gitea API v1 Endpunkte:
|
||||
|
||||
- `GET /api/v1/user/repos` - Repositories auflisten
|
||||
- `GET /api/v1/repos/{owner}/{repo}` - Repository abrufen
|
||||
- `POST /api/v1/user/repos` - Repository erstellen
|
||||
- `PATCH /api/v1/repos/{owner}/{repo}` - Repository aktualisieren
|
||||
- `DELETE /api/v1/repos/{owner}/{repo}` - Repository löschen
|
||||
- `GET /api/v1/user` - Aktueller User
|
||||
- `GET /api/v1/users/{username}` - User abrufen
|
||||
- `GET /api/v1/users/search` - User suchen
|
||||
- `GET /api/v1/repos/{owner}/{repo}/issues` - Issues auflisten
|
||||
- `GET /api/v1/repos/{owner}/{repo}/issues/{index}` - Issue abrufen
|
||||
- `POST /api/v1/repos/{owner}/{repo}/issues` - Issue erstellen
|
||||
- `PATCH /api/v1/repos/{owner}/{repo}/issues/{index}` - Issue aktualisieren
|
||||
- `GET /api/v1/repos/{owner}/{repo}/actions/workflows` - Workflows auflisten
|
||||
- `GET /api/v1/repos/{owner}/{repo}/actions/runs` - Runs auflisten
|
||||
- `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}` - Run Details
|
||||
- `POST /api/v1/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches` - Workflow triggern
|
||||
- `POST /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` - Run abbrechen
|
||||
- `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/logs` - Run Logs
|
||||
|
||||
## Beispiele
|
||||
|
||||
### Repository-Verwaltung
|
||||
|
||||
```php
|
||||
// Alle Repositories auflisten
|
||||
$repos = $client->repositories->list();
|
||||
|
||||
// Neues Repository erstellen
|
||||
$newRepo = $client->repositories->create([
|
||||
'name' => 'my-new-repo',
|
||||
'description' => 'A new repository',
|
||||
'private' => true,
|
||||
'auto_init' => true
|
||||
]);
|
||||
|
||||
// Repository aktualisieren
|
||||
$updatedRepo = $client->repositories->update('owner', 'repo-name', [
|
||||
'description' => 'Updated description'
|
||||
]);
|
||||
|
||||
// Repository löschen
|
||||
$client->repositories->delete('owner', 'repo-name');
|
||||
```
|
||||
|
||||
### Issue-Verwaltung
|
||||
|
||||
```php
|
||||
// Alle Issues auflisten
|
||||
$openIssues = $client->issues->list('owner', 'repo-name', ['state' => 'open']);
|
||||
|
||||
// Neues Issue erstellen
|
||||
$issue = $client->issues->create('owner', 'repo-name', [
|
||||
'title' => 'Bug: Something is broken',
|
||||
'body' => 'Detailed description of the bug',
|
||||
'labels' => [1] // Label ID
|
||||
]);
|
||||
|
||||
// Issue schließen
|
||||
$closedIssue = $client->issues->close('owner', 'repo-name', 1);
|
||||
```
|
||||
|
||||
### User-Informationen
|
||||
|
||||
```php
|
||||
// Aktuellen User abrufen
|
||||
$currentUser = $client->users->getCurrent();
|
||||
echo "Logged in as: " . $currentUser['login'];
|
||||
|
||||
// User suchen
|
||||
$users = $client->users->list(['q' => 'john', 'limit' => 10]);
|
||||
```
|
||||
|
||||
### Workflow/Action Management
|
||||
|
||||
```php
|
||||
// Alle Workflows auflisten
|
||||
$workflows = $client->actions->listWorkflows('owner', 'repo-name');
|
||||
|
||||
// Alle Runs auflisten
|
||||
$runs = $client->actions->listRuns('owner', 'repo-name', ['status' => 'running']);
|
||||
|
||||
// Workflow manuell triggern (für Testing)
|
||||
$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml', [
|
||||
'skip_tests' => false,
|
||||
'environment' => 'staging'
|
||||
], 'main');
|
||||
|
||||
// Run Status prüfen
|
||||
$status = $client->actions->getRunStatus('owner', 'repo-name', $runId);
|
||||
if ($status === 'running') {
|
||||
echo "Workflow läuft noch...";
|
||||
}
|
||||
|
||||
// Run Logs abrufen
|
||||
$logs = $client->actions->getLogs('owner', 'repo-name', $runId);
|
||||
file_put_contents('workflow-logs.txt', $logs);
|
||||
|
||||
// Laufenden Run abbrechen
|
||||
$client->actions->cancelRun('owner', 'repo-name', $runId);
|
||||
```
|
||||
|
||||
### Workflow Testing Workflow
|
||||
|
||||
```php
|
||||
// 1. Workflow triggern
|
||||
$client->actions->triggerWorkflow('owner', 'repo-name', 'test.yml', [], 'test-branch');
|
||||
|
||||
// 2. Warten und Status prüfen
|
||||
do {
|
||||
sleep(5);
|
||||
$runs = $client->actions->listRuns('owner', 'repo-name', ['limit' => 1]);
|
||||
$latestRun = $runs['workflow_runs'][0] ?? null;
|
||||
$status = $latestRun['status'] ?? 'unknown';
|
||||
} while ($status === 'running' || $status === 'waiting');
|
||||
|
||||
// 3. Logs abrufen wenn abgeschlossen
|
||||
if ($latestRun) {
|
||||
$logs = $client->actions->getLogs('owner', 'repo-name', $latestRun['id']);
|
||||
echo $logs;
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Token-Authentifizierung bevorzugen**: Token sind sicherer als Username/Password
|
||||
2. **Fehlerbehandlung**: Immer `ApiException` abfangen
|
||||
3. **Pagination**: Bei großen Listen Pagination-Parameter verwenden
|
||||
4. **Rate Limiting**: Gitea API Rate Limits beachten
|
||||
5. **Dependency Injection**: Client über DI Container verwenden
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Invalid authentication credentials"
|
||||
|
||||
- Überprüfe, ob Token oder Username/Password korrekt sind
|
||||
- Stelle sicher, dass der Token die benötigten Berechtigungen hat
|
||||
|
||||
### "Repository not found"
|
||||
|
||||
- Überprüfe, ob Owner und Repository-Name korrekt sind
|
||||
- Stelle sicher, dass der User Zugriff auf das Repository hat
|
||||
|
||||
### "API rate limit exceeded"
|
||||
|
||||
- Reduziere die Anzahl der API-Aufrufe
|
||||
- Implementiere Retry-Logik mit Exponential Backoff
|
||||
|
||||
### "Workflow not found"
|
||||
|
||||
- Überprüfe, ob der Workflow-Dateiname korrekt ist (z.B. "ci.yml")
|
||||
- Stelle sicher, dass der Workflow im `.gitea/workflows/` Verzeichnis existiert
|
||||
|
||||
### "Workflow run already completed"
|
||||
|
||||
- Workflow Runs können nur abgebrochen werden, wenn sie noch laufen
|
||||
- Prüfe den Status mit `getRunStatus()` vor dem Abbrechen
|
||||
|
||||
94
src/Infrastructure/Api/Gitea/RepositoryService.php
Normal file
94
src/Infrastructure/Api/Gitea/RepositoryService.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
|
||||
final readonly class RepositoryService
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiClient $apiClient
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Listet alle Repositories des authentifizierten Users
|
||||
*
|
||||
* @param array $options Optionale Parameter (page, limit, etc.)
|
||||
* @return array Liste der Repositories
|
||||
*/
|
||||
public function list(array $options = []): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::GET,
|
||||
'user/repos',
|
||||
[],
|
||||
$options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft ein Repository ab
|
||||
*
|
||||
* @param string $owner Repository Owner (Username oder Organization)
|
||||
* @param string $repo Repository Name
|
||||
* @return array Repository-Daten
|
||||
*/
|
||||
public function get(string $owner, string $repo): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::GET,
|
||||
"repos/{$owner}/{$repo}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein neues Repository
|
||||
*
|
||||
* @param array $data Repository-Daten (name, description, private, auto_init, etc.)
|
||||
* @return array Erstelltes Repository
|
||||
*/
|
||||
public function create(array $data): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::POST,
|
||||
'user/repos',
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert ein Repository
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @param array $data Zu aktualisierende Daten
|
||||
* @return array Aktualisiertes Repository
|
||||
*/
|
||||
public function update(string $owner, string $repo, array $data): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::PATCH,
|
||||
"repos/{$owner}/{$repo}",
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht ein Repository
|
||||
*
|
||||
* @param string $owner Repository Owner
|
||||
* @param string $repo Repository Name
|
||||
* @return void
|
||||
*/
|
||||
public function delete(string $owner, string $repo): void
|
||||
{
|
||||
$this->apiClient->sendRawRequest(
|
||||
Method::DELETE,
|
||||
"repos/{$owner}/{$repo}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
59
src/Infrastructure/Api/Gitea/UserService.php
Normal file
59
src/Infrastructure/Api/Gitea/UserService.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
|
||||
final readonly class UserService
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiClient $apiClient
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft den aktuellen authentifizierten User ab
|
||||
*
|
||||
* @return array User-Daten
|
||||
*/
|
||||
public function getCurrent(): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::GET,
|
||||
'user'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft einen User anhand des Usernames ab
|
||||
*
|
||||
* @param string $username Username
|
||||
* @return array User-Daten
|
||||
*/
|
||||
public function get(string $username): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::GET,
|
||||
"users/{$username}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht nach Usern
|
||||
*
|
||||
* @param array $options Optionale Parameter (q, page, limit, etc.)
|
||||
* @return array Liste der User
|
||||
*/
|
||||
public function list(array $options = []): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::GET,
|
||||
'users/search',
|
||||
[],
|
||||
$options
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
42
src/Infrastructure/Api/Gitea/ValueObjects/RunConclusion.php
Normal file
42
src/Infrastructure/Api/Gitea/ValueObjects/RunConclusion.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
/**
|
||||
* Workflow Run Conclusion
|
||||
*
|
||||
* Represents the final outcome of a completed workflow run.
|
||||
*/
|
||||
enum RunConclusion: string
|
||||
{
|
||||
case SUCCESS = 'success';
|
||||
case FAILURE = 'failure';
|
||||
case CANCELLED = 'cancelled';
|
||||
case SKIPPED = 'skipped';
|
||||
|
||||
/**
|
||||
* Check if the conclusion indicates success
|
||||
*/
|
||||
public function isSuccessful(): bool
|
||||
{
|
||||
return $this === self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the conclusion indicates failure
|
||||
*/
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this === self::FAILURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the run was manually cancelled
|
||||
*/
|
||||
public function wasCancelled(): bool
|
||||
{
|
||||
return $this === self::CANCELLED;
|
||||
}
|
||||
}
|
||||
66
src/Infrastructure/Api/Gitea/ValueObjects/RunId.php
Normal file
66
src/Infrastructure/Api/Gitea/ValueObjects/RunId.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
/**
|
||||
* Workflow Run ID
|
||||
*
|
||||
* Type-safe wrapper for workflow run identifiers.
|
||||
*/
|
||||
final readonly class RunId implements \Stringable
|
||||
{
|
||||
public function __construct(
|
||||
public int $value
|
||||
) {
|
||||
if ($value <= 0) {
|
||||
throw new \InvalidArgumentException('Run ID must be a positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RunId from string representation
|
||||
*/
|
||||
public static function fromString(string $id): self
|
||||
{
|
||||
if (!is_numeric($id)) {
|
||||
throw new \InvalidArgumentException('Run ID must be numeric');
|
||||
}
|
||||
|
||||
return new self((int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RunId from API response
|
||||
*/
|
||||
public static function fromApiResponse(int|string $id): self
|
||||
{
|
||||
if (is_string($id)) {
|
||||
return self::fromString($id);
|
||||
}
|
||||
|
||||
return new self($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another RunId
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to string representation
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return (string) $this->value;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toString();
|
||||
}
|
||||
}
|
||||
37
src/Infrastructure/Api/Gitea/ValueObjects/RunStatus.php
Normal file
37
src/Infrastructure/Api/Gitea/ValueObjects/RunStatus.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
/**
|
||||
* Workflow Run Status
|
||||
*
|
||||
* Represents the current execution state of a workflow run.
|
||||
*/
|
||||
enum RunStatus: string
|
||||
{
|
||||
case COMPLETED = 'completed';
|
||||
case IN_PROGRESS = 'in_progress';
|
||||
case QUEUED = 'queued';
|
||||
case WAITING = 'waiting';
|
||||
|
||||
/**
|
||||
* Check if the run is in a terminal state
|
||||
*/
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return $this === self::COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the run is actively executing
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::IN_PROGRESS, self::QUEUED, self::WAITING => true,
|
||||
self::COMPLETED => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
70
src/Infrastructure/Api/Gitea/ValueObjects/Workflow.php
Normal file
70
src/Infrastructure/Api/Gitea/ValueObjects/Workflow.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
/**
|
||||
* Workflow
|
||||
*
|
||||
* Represents a workflow definition in Gitea Actions.
|
||||
*/
|
||||
final readonly class Workflow
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $name,
|
||||
public string $path,
|
||||
public string $state,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create Workflow from Gitea API response
|
||||
*/
|
||||
public static function fromApiResponse(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: (int) $data['id'],
|
||||
name: $data['name'],
|
||||
path: $data['path'],
|
||||
state: $data['state'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the workflow is active
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->state === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the workflow is disabled
|
||||
*/
|
||||
public function isDisabled(): bool
|
||||
{
|
||||
return $this->state === 'disabled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workflow file name
|
||||
*/
|
||||
public function getFileName(): string
|
||||
{
|
||||
return basename($this->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array representation
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'path' => $this->path,
|
||||
'state' => $this->state,
|
||||
];
|
||||
}
|
||||
}
|
||||
153
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowList.php
Normal file
153
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowList.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
use IteratorAggregate;
|
||||
use Countable;
|
||||
use ArrayIterator;
|
||||
|
||||
/**
|
||||
* Workflow List
|
||||
*
|
||||
* Type-safe collection of Workflow objects.
|
||||
*/
|
||||
final readonly class WorkflowList implements IteratorAggregate, Countable
|
||||
{
|
||||
/** @var Workflow[] */
|
||||
private array $workflows;
|
||||
|
||||
/**
|
||||
* @param Workflow[] $workflows
|
||||
*/
|
||||
public function __construct(array $workflows)
|
||||
{
|
||||
// Validate all items are Workflow instances
|
||||
foreach ($workflows as $workflow) {
|
||||
if (!$workflow instanceof Workflow) {
|
||||
throw new \InvalidArgumentException(
|
||||
'All items must be Workflow instances'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->workflows = array_values($workflows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from Gitea API response
|
||||
*/
|
||||
public static function fromApiResponse(array $data): self
|
||||
{
|
||||
$workflows = [];
|
||||
|
||||
foreach ($data['workflows'] ?? [] as $workflowData) {
|
||||
$workflows[] = Workflow::fromApiResponse($workflowData);
|
||||
}
|
||||
|
||||
return new self($workflows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all workflows
|
||||
*
|
||||
* @return Workflow[]
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->workflows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active workflows
|
||||
*
|
||||
* @return Workflow[]
|
||||
*/
|
||||
public function active(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->workflows,
|
||||
fn(Workflow $workflow) => $workflow->isActive()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disabled workflows
|
||||
*
|
||||
* @return Workflow[]
|
||||
*/
|
||||
public function disabled(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->workflows,
|
||||
fn(Workflow $workflow) => $workflow->isDisabled()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find workflow by ID
|
||||
*/
|
||||
public function findById(int $id): ?Workflow
|
||||
{
|
||||
foreach ($this->workflows as $workflow) {
|
||||
if ($workflow->id === $id) {
|
||||
return $workflow;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find workflow by name
|
||||
*/
|
||||
public function findByName(string $name): ?Workflow
|
||||
{
|
||||
foreach ($this->workflows as $workflow) {
|
||||
if ($workflow->name === $name) {
|
||||
return $workflow;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if list is empty
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->workflows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of workflows
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->workflows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get iterator for foreach support
|
||||
*/
|
||||
public function getIterator(): ArrayIterator
|
||||
{
|
||||
return new ArrayIterator($this->workflows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array representation
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'workflows' => array_map(
|
||||
fn(Workflow $workflow) => $workflow->toArray(),
|
||||
$this->workflows
|
||||
),
|
||||
'total_count' => $this->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
154
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRun.php
Normal file
154
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRun.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\{Timestamp, Duration};
|
||||
|
||||
/**
|
||||
* Workflow Run
|
||||
*
|
||||
* Represents a complete workflow run with type-safe properties and business logic.
|
||||
*/
|
||||
final readonly class WorkflowRun
|
||||
{
|
||||
public function __construct(
|
||||
public RunId $id,
|
||||
public string $displayTitle,
|
||||
public RunStatus $status,
|
||||
public ?RunConclusion $conclusion,
|
||||
public Timestamp $startedAt,
|
||||
public ?Timestamp $completedAt,
|
||||
public string $headBranch,
|
||||
public string $headSha,
|
||||
public int $runNumber,
|
||||
public string $event,
|
||||
public ?string $name = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create WorkflowRun from Gitea API response
|
||||
*/
|
||||
public static function fromApiResponse(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: RunId::fromApiResponse($data['id']),
|
||||
displayTitle: $data['display_title'] ?? $data['name'] ?? 'Unknown',
|
||||
status: RunStatus::from($data['status']),
|
||||
conclusion: isset($data['conclusion']) && $data['conclusion'] !== null
|
||||
? RunConclusion::from($data['conclusion'])
|
||||
: null,
|
||||
startedAt: Timestamp::fromDateTime(new \DateTimeImmutable($data['started_at'] ?? $data['run_started_at'])),
|
||||
completedAt: isset($data['completed_at']) && $data['completed_at'] !== null
|
||||
? Timestamp::fromDateTime(new \DateTimeImmutable($data['completed_at']))
|
||||
: null,
|
||||
headBranch: $data['head_branch'] ?? 'unknown',
|
||||
headSha: $data['head_sha'] ?? '',
|
||||
runNumber: (int) $data['run_number'],
|
||||
event: $data['event'],
|
||||
name: $data['name'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the workflow run completed successfully
|
||||
*/
|
||||
public function isSuccessful(): bool
|
||||
{
|
||||
return $this->status === RunStatus::COMPLETED
|
||||
&& $this->conclusion?->isSuccessful() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the workflow run failed
|
||||
*/
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === RunStatus::COMPLETED
|
||||
&& $this->conclusion?->isFailed() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the workflow run is currently executing
|
||||
*/
|
||||
public function isRunning(): bool
|
||||
{
|
||||
return $this->status->isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the workflow run is completed (any conclusion)
|
||||
*/
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === RunStatus::COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the workflow run was cancelled
|
||||
*/
|
||||
public function wasCancelled(): bool
|
||||
{
|
||||
return $this->conclusion?->wasCancelled() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the duration of the workflow run
|
||||
*
|
||||
* Returns null if the run hasn't completed yet.
|
||||
*/
|
||||
public function getDuration(): ?Duration
|
||||
{
|
||||
if ($this->completedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Duration::between($this->startedAt, $this->completedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the elapsed time since the run started
|
||||
*
|
||||
* Returns duration even for running workflows.
|
||||
*/
|
||||
public function getElapsedTime(): Duration
|
||||
{
|
||||
$endTime = $this->completedAt ?? Timestamp::now();
|
||||
return Duration::between($this->startedAt, $endTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable status summary
|
||||
*/
|
||||
public function getStatusSummary(): string
|
||||
{
|
||||
return match (true) {
|
||||
$this->isSuccessful() => "✅ Successful",
|
||||
$this->isFailed() => "❌ Failed",
|
||||
$this->wasCancelled() => "🚫 Cancelled",
|
||||
$this->isRunning() => "🔄 Running",
|
||||
default => "⏳ {$this->status->value}",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array representation (compatible with API format)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id->value,
|
||||
'display_title' => $this->displayTitle,
|
||||
'status' => $this->status->value,
|
||||
'conclusion' => $this->conclusion?->value,
|
||||
'started_at' => $this->startedAt->format('Y-m-d H:i:s'),
|
||||
'completed_at' => $this->completedAt?->format('Y-m-d H:i:s'),
|
||||
'head_branch' => $this->headBranch,
|
||||
'head_sha' => $this->headSha,
|
||||
'run_number' => $this->runNumber,
|
||||
'event' => $this->event,
|
||||
'name' => $this->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
207
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRunsList.php
Normal file
207
src/Infrastructure/Api/Gitea/ValueObjects/WorkflowRunsList.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api\Gitea\ValueObjects;
|
||||
|
||||
use IteratorAggregate;
|
||||
use Countable;
|
||||
use ArrayIterator;
|
||||
|
||||
/**
|
||||
* Workflow Runs List
|
||||
*
|
||||
* Type-safe collection of WorkflowRun objects.
|
||||
*/
|
||||
final readonly class WorkflowRunsList implements IteratorAggregate, Countable
|
||||
{
|
||||
/** @var WorkflowRun[] */
|
||||
private array $runs;
|
||||
|
||||
/**
|
||||
* @param WorkflowRun[] $runs
|
||||
*/
|
||||
public function __construct(array $runs)
|
||||
{
|
||||
// Validate all items are WorkflowRun instances
|
||||
foreach ($runs as $run) {
|
||||
if (!$run instanceof WorkflowRun) {
|
||||
throw new \InvalidArgumentException(
|
||||
'All items must be WorkflowRun instances'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->runs = array_values($runs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from Gitea API response
|
||||
*/
|
||||
public static function fromApiResponse(array $data): self
|
||||
{
|
||||
$runs = [];
|
||||
|
||||
foreach ($data['workflow_runs'] ?? [] as $runData) {
|
||||
$runs[] = WorkflowRun::fromApiResponse($runData);
|
||||
}
|
||||
|
||||
return new self($runs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all runs
|
||||
*
|
||||
* @return WorkflowRun[]
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->runs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get runs that are currently running
|
||||
*
|
||||
* @return WorkflowRun[]
|
||||
*/
|
||||
public function running(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->runs,
|
||||
fn(WorkflowRun $run) => $run->isRunning()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get runs that completed successfully
|
||||
*
|
||||
* @return WorkflowRun[]
|
||||
*/
|
||||
public function successful(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->runs,
|
||||
fn(WorkflowRun $run) => $run->isSuccessful()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get runs that failed
|
||||
*
|
||||
* @return WorkflowRun[]
|
||||
*/
|
||||
public function failed(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->runs,
|
||||
fn(WorkflowRun $run) => $run->isFailed()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find run by ID
|
||||
*/
|
||||
public function findById(RunId $id): ?WorkflowRun
|
||||
{
|
||||
foreach ($this->runs as $run) {
|
||||
if ($run->id->equals($id)) {
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest run
|
||||
*/
|
||||
public function latest(): ?WorkflowRun
|
||||
{
|
||||
if (empty($this->runs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->runs[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter runs by branch
|
||||
*
|
||||
* @return WorkflowRun[]
|
||||
*/
|
||||
public function forBranch(string $branch): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->runs,
|
||||
fn(WorkflowRun $run) => $run->headBranch === $branch
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of successful runs
|
||||
*/
|
||||
public function successCount(): int
|
||||
{
|
||||
return count($this->successful());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of failed runs
|
||||
*/
|
||||
public function failureCount(): int
|
||||
{
|
||||
return count($this->failed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate success rate (0.0 to 1.0)
|
||||
*/
|
||||
public function successRate(): float
|
||||
{
|
||||
$total = $this->count();
|
||||
|
||||
if ($total === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $this->successCount() / $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if list is empty
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->runs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of runs
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->runs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get iterator for foreach support
|
||||
*/
|
||||
public function getIterator(): ArrayIterator
|
||||
{
|
||||
return new ArrayIterator($this->runs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array representation
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'workflow_runs' => array_map(
|
||||
fn(WorkflowRun $run) => $run->toArray(),
|
||||
$this->runs
|
||||
),
|
||||
'total_count' => $this->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,44 @@ $client = new GitHubClient('github_personal_access_token');
|
||||
$repo = $client->getRepository('username', 'repo-name');
|
||||
```
|
||||
|
||||
### GiteaClient
|
||||
|
||||
Integration mit der Gitea API v1 für Repository-, User- und Issue-Verwaltung:
|
||||
|
||||
```php
|
||||
use App\Infrastructure\Api\Gitea\GiteaClient;
|
||||
|
||||
// Über Dependency Injection (empfohlen)
|
||||
$client = $container->get(GiteaClient::class);
|
||||
|
||||
// Oder manuell
|
||||
use App\Infrastructure\Api\Gitea\GiteaConfig;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
|
||||
$config = new GiteaConfig(
|
||||
baseUrl: 'https://git.michaelschiemer.de',
|
||||
token: 'your_access_token'
|
||||
);
|
||||
$client = new GiteaClient($config, new CurlHttpClient());
|
||||
|
||||
// Repository-Operationen
|
||||
$repos = $client->repositories->list();
|
||||
$repo = $client->repositories->get('owner', 'repo-name');
|
||||
|
||||
// User-Operationen
|
||||
$currentUser = $client->users->getCurrent();
|
||||
$user = $client->users->get('username');
|
||||
|
||||
// Issue-Operationen
|
||||
$issues = $client->issues->list('owner', 'repo-name');
|
||||
$issue = $client->issues->create('owner', 'repo-name', [
|
||||
'title' => 'New Issue',
|
||||
'body' => 'Issue description'
|
||||
]);
|
||||
```
|
||||
|
||||
Siehe [Gitea/README.md](Gitea/README.md) für detaillierte Dokumentation.
|
||||
|
||||
## Implementierung eines neuen API-Clients
|
||||
|
||||
Neue API-Clients können einfach durch Verwendung des `ApiRequestTrait` erstellt werden:
|
||||
|
||||
@@ -56,7 +56,7 @@ final readonly class CreateComponentStateTable implements Migration
|
||||
|
||||
public function getVersion(): MigrationVersion
|
||||
{
|
||||
return MigrationVersion::fromString('2024_12_20_120000');
|
||||
return MigrationVersion::fromTimestamp('2024_12_20_120000');
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
|
||||
690
src/Infrastructure/Storage/MinIoClient.php
Normal file
690
src/Infrastructure/Storage/MinIoClient.php
Normal file
@@ -0,0 +1,690 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Storage;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Hash;
|
||||
use App\Framework\Core\ValueObjects\HashAlgorithm;
|
||||
use App\Framework\Encryption\HmacService;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\HttpClient\ClientRequest;
|
||||
use App\Framework\HttpClient\ClientResponse;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
use App\Framework\Storage\Exceptions\StorageConnectionException;
|
||||
use App\Framework\Storage\Exceptions\StorageOperationException;
|
||||
|
||||
/**
|
||||
* MinIO/S3-compatible client with AWS Signature Version 4
|
||||
*
|
||||
* Dependency-free implementation using framework modules:
|
||||
* - RandomGenerator for cryptographic random bytes
|
||||
* - HmacService for HMAC-SHA256 signatures
|
||||
* - Hash Value Objects for payload hashing
|
||||
* - CurlHttpClient for HTTP requests
|
||||
*/
|
||||
final readonly class MinIoClient
|
||||
{
|
||||
private const string SERVICE_NAME = 's3';
|
||||
private const string SIGNATURE_VERSION = 'AWS4-HMAC-SHA256';
|
||||
private const string ALGORITHM = 'AWS4-HMAC-SHA256';
|
||||
|
||||
private string $endpoint;
|
||||
|
||||
public function __construct(
|
||||
string $endpoint,
|
||||
private string $accessKey,
|
||||
private string $secretKey,
|
||||
private string $region = 'us-east-1',
|
||||
private bool $usePathStyle = true,
|
||||
private RandomGenerator $randomGenerator,
|
||||
private HmacService $hmacService,
|
||||
private CurlHttpClient $httpClient
|
||||
) {
|
||||
// Normalize endpoint (remove trailing slash)
|
||||
$this->endpoint = rtrim($endpoint, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload object to bucket
|
||||
*
|
||||
* @param array<string, string> $headers Additional headers (e.g., Content-Type)
|
||||
* @return array{etag: string, size: int, contentType: ?string}
|
||||
*/
|
||||
public function putObject(string $bucket, string $key, string $body, array $headers = []): array
|
||||
{
|
||||
$url = $this->buildUrl($bucket, $key);
|
||||
$requestHeaders = $this->buildHeaders($headers);
|
||||
$payloadHash = Hash::sha256($body)->toString();
|
||||
|
||||
$signedRequest = $this->signRequest(
|
||||
method: Method::PUT,
|
||||
url: $url,
|
||||
headers: $requestHeaders,
|
||||
payloadHash: $payloadHash,
|
||||
body: $body
|
||||
);
|
||||
|
||||
$response = $this->sendRequest($signedRequest);
|
||||
|
||||
if (! $response->status->isSuccess()) {
|
||||
throw StorageOperationException::for('put', $bucket, $key, "HTTP {$response->status->value}");
|
||||
}
|
||||
|
||||
$etag = $this->extractEtag($response->headers);
|
||||
$contentType = $requestHeaders->get('Content-Type')?->value() ?? null;
|
||||
|
||||
return [
|
||||
'etag' => $etag,
|
||||
'size' => strlen($body),
|
||||
'contentType' => $contentType,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Download object from bucket
|
||||
*/
|
||||
public function getObject(string $bucket, string $key): string
|
||||
{
|
||||
$url = $this->buildUrl($bucket, $key);
|
||||
$requestHeaders = $this->buildHeaders([]);
|
||||
$payloadHash = Hash::sha256('')->toString(); // Empty body for GET
|
||||
|
||||
$signedRequest = $this->signRequest(
|
||||
method: Method::GET,
|
||||
url: $url,
|
||||
headers: $requestHeaders,
|
||||
payloadHash: $payloadHash
|
||||
);
|
||||
|
||||
$response = $this->sendRequest($signedRequest);
|
||||
|
||||
if ($response->status->value === 404) {
|
||||
throw StorageOperationException::for('get', $bucket, $key, 'Object not found');
|
||||
}
|
||||
|
||||
if (! $response->status->isSuccess()) {
|
||||
throw StorageOperationException::for('get', $bucket, $key, "HTTP {$response->status->value}");
|
||||
}
|
||||
|
||||
return $response->body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object metadata (HEAD request)
|
||||
*
|
||||
* @return array{etag: ?string, size: ?int, contentType: ?string, lastModified: ?int}
|
||||
*/
|
||||
public function headObject(string $bucket, string $key): array
|
||||
{
|
||||
$url = $this->buildUrl($bucket, $key);
|
||||
$requestHeaders = $this->buildHeaders([]);
|
||||
$payloadHash = Hash::sha256('')->toString();
|
||||
|
||||
$signedRequest = $this->signRequest(
|
||||
method: Method::HEAD,
|
||||
url: $url,
|
||||
headers: $requestHeaders,
|
||||
payloadHash: $payloadHash
|
||||
);
|
||||
|
||||
$response = $this->sendRequest($signedRequest);
|
||||
|
||||
if ($response->status->value === 404) {
|
||||
throw StorageOperationException::for('head', $bucket, $key, 'Object not found');
|
||||
}
|
||||
|
||||
if (! $response->status->isSuccess()) {
|
||||
throw StorageOperationException::for('head', $bucket, $key, "HTTP {$response->status->value}");
|
||||
}
|
||||
|
||||
$etag = $this->extractEtag($response->headers);
|
||||
$size = $this->extractSize($response->headers);
|
||||
$contentType = $this->extractContentType($response->headers);
|
||||
$lastModified = $this->extractLastModified($response->headers);
|
||||
|
||||
return [
|
||||
'etag' => $etag,
|
||||
'size' => $size,
|
||||
'contentType' => $contentType,
|
||||
'lastModified' => $lastModified,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete object from bucket
|
||||
*/
|
||||
public function deleteObject(string $bucket, string $key): void
|
||||
{
|
||||
$url = $this->buildUrl($bucket, $key);
|
||||
$requestHeaders = $this->buildHeaders([]);
|
||||
$payloadHash = Hash::sha256('')->toString();
|
||||
|
||||
$signedRequest = $this->signRequest(
|
||||
method: Method::DELETE,
|
||||
url: $url,
|
||||
headers: $requestHeaders,
|
||||
payloadHash: $payloadHash
|
||||
);
|
||||
|
||||
$response = $this->sendRequest($signedRequest);
|
||||
|
||||
if ($response->status->value === 404) {
|
||||
// Object doesn't exist, but that's OK for delete operations
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $response->status->isSuccess()) {
|
||||
throw StorageOperationException::for('delete', $bucket, $key, "HTTP {$response->status->value}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if object exists
|
||||
*/
|
||||
public function objectExists(string $bucket, string $key): bool
|
||||
{
|
||||
try {
|
||||
$this->headObject($bucket, $key);
|
||||
|
||||
return true;
|
||||
} catch (StorageOperationException $e) {
|
||||
if ($e->getCode() === 404) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream object content to destination
|
||||
*
|
||||
* Streams HTTP response directly to a writable stream resource.
|
||||
* Useful for large file downloads without loading entire response into memory.
|
||||
*
|
||||
* @param string $bucket Bucket name
|
||||
* @param string $key Object key
|
||||
* @param resource $destination Writable stream resource
|
||||
* @param array<string, mixed> $opts Optional parameters (e.g., 'bufferSize')
|
||||
* @return int Number of bytes written
|
||||
* @throws StorageOperationException
|
||||
*/
|
||||
public function getObjectToStream(string $bucket, string $key, $destination, array $opts = []): int
|
||||
{
|
||||
if (! is_resource($destination)) {
|
||||
throw StorageOperationException::for('getObjectToStream', $bucket, $key, 'Invalid destination stream');
|
||||
}
|
||||
|
||||
$url = $this->buildUrl($bucket, $key);
|
||||
$requestHeaders = $this->buildHeaders([]);
|
||||
$payloadHash = Hash::sha256('')->toString(); // Empty body for GET
|
||||
|
||||
$signedRequest = $this->signRequest(
|
||||
method: Method::GET,
|
||||
url: $url,
|
||||
headers: $requestHeaders,
|
||||
payloadHash: $payloadHash
|
||||
);
|
||||
|
||||
try {
|
||||
$streamingResponse = $this->httpClient->sendStreaming($signedRequest, $destination);
|
||||
|
||||
if ($streamingResponse->status->value === 404) {
|
||||
throw StorageOperationException::for('getObjectToStream', $bucket, $key, 'Object not found');
|
||||
}
|
||||
|
||||
if (! $streamingResponse->status->isSuccess()) {
|
||||
throw StorageOperationException::for('getObjectToStream', $bucket, $key, "HTTP {$streamingResponse->status->value}");
|
||||
}
|
||||
|
||||
return $streamingResponse->bytesWritten;
|
||||
} catch (StorageOperationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw StorageConnectionException::for($this->endpoint, $e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload object from stream
|
||||
*
|
||||
* Streams HTTP request body from a readable stream resource.
|
||||
* Useful for large file uploads without loading entire file into memory.
|
||||
*
|
||||
* @param string $bucket Bucket name
|
||||
* @param string $key Object key
|
||||
* @param resource $source Readable stream resource
|
||||
* @param array<string, mixed> $opts Optional parameters:
|
||||
* - 'headers' => array<string, string> Additional headers (e.g., Content-Type)
|
||||
* - 'contentLength' => int Content-Length in bytes (null for chunked transfer)
|
||||
* @return array{etag: string, size: int, contentType: ?string}
|
||||
* @throws StorageOperationException
|
||||
*/
|
||||
public function putObjectFromStream(string $bucket, string $key, $source, array $opts = []): array
|
||||
{
|
||||
if (! is_resource($source)) {
|
||||
throw StorageOperationException::for('putObjectFromStream', $bucket, $key, 'Invalid source stream');
|
||||
}
|
||||
|
||||
$url = $this->buildUrl($bucket, $key);
|
||||
$additionalHeaders = $opts['headers'] ?? [];
|
||||
$requestHeaders = $this->buildHeaders($additionalHeaders);
|
||||
|
||||
// Try to get content length if available
|
||||
$contentLength = $opts['contentLength'] ?? $this->getStreamSize($source);
|
||||
|
||||
// For streaming uploads, we use "UNSIGNED-PAYLOAD" for AWS SigV4
|
||||
// This allows streaming without reading the entire stream to calculate hash
|
||||
$payloadHash = 'UNSIGNED-PAYLOAD';
|
||||
|
||||
$signedRequest = $this->signRequest(
|
||||
method: Method::PUT,
|
||||
url: $url,
|
||||
headers: $requestHeaders,
|
||||
payloadHash: $payloadHash
|
||||
);
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->sendStreamingUpload($signedRequest, $source, $contentLength);
|
||||
|
||||
if (! $response->status->isSuccess()) {
|
||||
throw StorageOperationException::for('putObjectFromStream', $bucket, $key, "HTTP {$response->status->value}");
|
||||
}
|
||||
|
||||
$etag = $this->extractEtag($response->headers);
|
||||
$contentType = $requestHeaders->get('Content-Type')?->value() ?? null;
|
||||
|
||||
return [
|
||||
'etag' => $etag,
|
||||
'size' => $contentLength ?? 0,
|
||||
'contentType' => $contentType,
|
||||
];
|
||||
} catch (StorageOperationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw StorageConnectionException::for($this->endpoint, $e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stream size if available
|
||||
*
|
||||
* @param resource $stream
|
||||
* @return int|null Size in bytes, or null if not available
|
||||
*/
|
||||
private function getStreamSize($stream): ?int
|
||||
{
|
||||
$meta = stream_get_meta_data($stream);
|
||||
$uri = $meta['uri'] ?? null;
|
||||
|
||||
if ($uri !== null && file_exists($uri)) {
|
||||
$size = filesize($uri);
|
||||
if ($size !== false) {
|
||||
return $size;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get size from fstat
|
||||
$stat = @fstat($stream);
|
||||
if ($stat !== false && isset($stat['size'])) {
|
||||
return $stat['size'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create presigned URL for temporary access
|
||||
*/
|
||||
public function createPresignedUrl(string $bucket, string $key, \DateInterval $ttl): string
|
||||
{
|
||||
$url = $this->buildUrl($bucket, $key);
|
||||
$now = time();
|
||||
$expires = $now + ($ttl->days * 86400) + ($ttl->h * 3600) + ($ttl->i * 60) + $ttl->s;
|
||||
|
||||
// Parse URL to add query parameters
|
||||
$parsedUrl = parse_url($url);
|
||||
$queryParams = [];
|
||||
if (isset($parsedUrl['query'])) {
|
||||
parse_str($parsedUrl['query'], $queryParams);
|
||||
}
|
||||
|
||||
$amzDate = gmdate('Ymd\THis\Z', $now);
|
||||
$dateStamp = gmdate('Ymd', $now);
|
||||
|
||||
$queryParams['X-Amz-Algorithm'] = self::ALGORITHM;
|
||||
$queryParams['X-Amz-Credential'] = $this->buildCredential($now);
|
||||
$queryParams['X-Amz-Date'] = $amzDate;
|
||||
$queryParams['X-Amz-Expires'] = (string) ($expires - $now);
|
||||
$queryParams['X-Amz-SignedHeaders'] = 'host';
|
||||
|
||||
$queryString = http_build_query($queryParams);
|
||||
$presignedUrl = $parsedUrl['scheme'] . '://' . $parsedUrl['host'];
|
||||
if (isset($parsedUrl['port'])) {
|
||||
$presignedUrl .= ':' . $parsedUrl['port'];
|
||||
}
|
||||
$presignedUrl .= $parsedUrl['path'] . '?' . $queryString;
|
||||
|
||||
// Sign the request
|
||||
$headers = new Headers();
|
||||
$payloadHash = Hash::sha256('')->toString();
|
||||
$signature = $this->calculateSignature(
|
||||
method: Method::GET,
|
||||
canonicalUri: $parsedUrl['path'],
|
||||
canonicalQueryString: $queryString,
|
||||
canonicalHeaders: $this->buildCanonicalHeaders($headers),
|
||||
signedHeaders: 'host',
|
||||
payloadHash: $payloadHash,
|
||||
timestamp: $now
|
||||
);
|
||||
|
||||
$presignedUrl .= '&X-Amz-Signature=' . $signature;
|
||||
|
||||
return $presignedUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL for bucket/key
|
||||
*/
|
||||
private function buildUrl(string $bucket, string $key): string
|
||||
{
|
||||
$key = ltrim($key, '/');
|
||||
$encodedKey = $this->encodeKey($key);
|
||||
|
||||
if ($this->usePathStyle) {
|
||||
// Path-style: http://endpoint/bucket/key
|
||||
return $this->endpoint . '/' . $bucket . '/' . $encodedKey;
|
||||
}
|
||||
|
||||
// Virtual-host-style: http://bucket.endpoint/key
|
||||
$host = parse_url($this->endpoint, PHP_URL_HOST);
|
||||
$port = parse_url($this->endpoint, PHP_URL_PORT);
|
||||
$scheme = parse_url($this->endpoint, PHP_URL_SCHEME) ?? 'http';
|
||||
|
||||
$url = $scheme . '://' . $bucket . '.' . $host;
|
||||
if ($port !== null) {
|
||||
$url .= ':' . $port;
|
||||
}
|
||||
$url .= '/' . $encodedKey;
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-encode key (S3-style encoding)
|
||||
*/
|
||||
private function encodeKey(string $key): string
|
||||
{
|
||||
// S3 encoding: preserve /, encode everything else
|
||||
return str_replace('%2F', '/', rawurlencode($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers with required S3 headers
|
||||
*
|
||||
* @param array<string, string> $additionalHeaders
|
||||
*/
|
||||
private function buildHeaders(array $additionalHeaders): Headers
|
||||
{
|
||||
$headers = new Headers();
|
||||
|
||||
foreach ($additionalHeaders as $name => $value) {
|
||||
$headers = $headers->with($name, $value);
|
||||
}
|
||||
|
||||
// Add host header
|
||||
$host = parse_url($this->endpoint, PHP_URL_HOST);
|
||||
$port = parse_url($this->endpoint, PHP_URL_PORT);
|
||||
$hostHeader = $port !== null ? "{$host}:{$port}" : $host;
|
||||
$headers = $headers->with('Host', $hostHeader);
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign request with AWS Signature Version 4
|
||||
*/
|
||||
private function signRequest(
|
||||
Method $method,
|
||||
string $url,
|
||||
Headers $headers,
|
||||
string $payloadHash,
|
||||
string $body = ''
|
||||
): ClientRequest {
|
||||
$parsedUrl = parse_url($url);
|
||||
$canonicalUri = $parsedUrl['path'] ?? '/';
|
||||
$canonicalQueryString = $parsedUrl['query'] ?? '';
|
||||
|
||||
// Build canonical headers
|
||||
$canonicalHeaders = $this->buildCanonicalHeaders($headers);
|
||||
$signedHeaders = $this->buildSignedHeaders($headers);
|
||||
|
||||
// Create canonical request
|
||||
$canonicalRequest = $this->buildCanonicalRequest(
|
||||
method: $method->value,
|
||||
canonicalUri: $canonicalUri,
|
||||
canonicalQueryString: $canonicalQueryString,
|
||||
canonicalHeaders: $canonicalHeaders,
|
||||
signedHeaders: $signedHeaders,
|
||||
payloadHash: $payloadHash
|
||||
);
|
||||
|
||||
// Create string to sign
|
||||
$timestamp = time();
|
||||
$dateStamp = gmdate('Ymd', $timestamp);
|
||||
$amzDate = gmdate('Ymd\THis\Z', $timestamp);
|
||||
$credentialScope = "{$dateStamp}/{$this->region}/" . self::SERVICE_NAME . '/aws4_request';
|
||||
|
||||
$stringToSign = self::ALGORITHM . "\n"
|
||||
. $amzDate . "\n"
|
||||
. $credentialScope . "\n"
|
||||
. Hash::sha256($canonicalRequest)->toString();
|
||||
|
||||
// Calculate signature
|
||||
$signature = $this->calculateSignature(
|
||||
method: $method,
|
||||
canonicalUri: $canonicalUri,
|
||||
canonicalQueryString: $canonicalQueryString,
|
||||
canonicalHeaders: $canonicalHeaders,
|
||||
signedHeaders: $signedHeaders,
|
||||
payloadHash: $payloadHash,
|
||||
timestamp: $timestamp
|
||||
);
|
||||
|
||||
// Add Authorization header
|
||||
$authorization = self::SIGNATURE_VERSION . ' '
|
||||
. 'Credential=' . $this->accessKey . '/' . $credentialScope . ', '
|
||||
. 'SignedHeaders=' . $signedHeaders . ', '
|
||||
. 'Signature=' . $signature;
|
||||
|
||||
$signedHeaders = $headers->with('Authorization', $authorization);
|
||||
$signedHeaders = $signedHeaders->with('X-Amz-Date', $amzDate);
|
||||
$signedHeaders = $signedHeaders->with('X-Amz-Content-Sha256', $payloadHash);
|
||||
|
||||
return new ClientRequest(
|
||||
method: $method,
|
||||
url: $url,
|
||||
headers: $signedHeaders,
|
||||
body: $body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build canonical headers string
|
||||
*/
|
||||
private function buildCanonicalHeaders(Headers $headers): string
|
||||
{
|
||||
$canonicalHeaders = [];
|
||||
$allHeaders = $headers->all();
|
||||
|
||||
foreach ($allHeaders as $name => $value) {
|
||||
$lowerName = strtolower($name);
|
||||
$canonicalHeaders[$lowerName] = trim($value);
|
||||
}
|
||||
|
||||
// Sort by header name
|
||||
ksort($canonicalHeaders);
|
||||
|
||||
$result = '';
|
||||
foreach ($canonicalHeaders as $name => $value) {
|
||||
$result .= $name . ':' . $value . "\n";
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build signed headers string (semicolon-separated list of header names)
|
||||
*/
|
||||
private function buildSignedHeaders(Headers $headers): string
|
||||
{
|
||||
$headerNames = [];
|
||||
foreach (array_keys($headers->all()) as $name) {
|
||||
$headerNames[] = strtolower($name);
|
||||
}
|
||||
|
||||
sort($headerNames);
|
||||
|
||||
return implode(';', $headerNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build canonical request string
|
||||
*/
|
||||
private function buildCanonicalRequest(
|
||||
string $method,
|
||||
string $canonicalUri,
|
||||
string $canonicalQueryString,
|
||||
string $canonicalHeaders,
|
||||
string $signedHeaders,
|
||||
string $payloadHash
|
||||
): string {
|
||||
return $method . "\n"
|
||||
. $canonicalUri . "\n"
|
||||
. $canonicalQueryString . "\n"
|
||||
. $canonicalHeaders . "\n"
|
||||
. $signedHeaders . "\n"
|
||||
. $payloadHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate AWS Signature Version 4 signature
|
||||
*/
|
||||
private function calculateSignature(
|
||||
Method $method,
|
||||
string $canonicalUri,
|
||||
string $canonicalQueryString,
|
||||
string $canonicalHeaders,
|
||||
string $signedHeaders,
|
||||
string $payloadHash,
|
||||
int $timestamp
|
||||
): string {
|
||||
$dateStamp = gmdate('Ymd', $timestamp);
|
||||
|
||||
// Build canonical request
|
||||
$canonicalRequest = $this->buildCanonicalRequest(
|
||||
method: $method->value,
|
||||
canonicalUri: $canonicalUri,
|
||||
canonicalQueryString: $canonicalQueryString,
|
||||
canonicalHeaders: $canonicalHeaders,
|
||||
signedHeaders: $signedHeaders,
|
||||
payloadHash: $payloadHash
|
||||
);
|
||||
|
||||
// Create string to sign
|
||||
$credentialScope = "{$dateStamp}/{$this->region}/" . self::SERVICE_NAME . '/aws4_request';
|
||||
$amzDate = gmdate('Ymd\THis\Z', $timestamp);
|
||||
$stringToSign = self::ALGORITHM . "\n"
|
||||
. $amzDate . "\n"
|
||||
. $credentialScope . "\n"
|
||||
. Hash::sha256($canonicalRequest)->toString();
|
||||
|
||||
// Calculate signing key
|
||||
$kDate = $this->hmacService->generateHmac($dateStamp, 'AWS4' . $this->secretKey, HashAlgorithm::SHA256);
|
||||
$kRegion = $this->hmacService->generateHmac($this->region, $kDate->toString(), HashAlgorithm::SHA256);
|
||||
$kService = $this->hmacService->generateHmac(self::SERVICE_NAME, $kRegion->toString(), HashAlgorithm::SHA256);
|
||||
$kSigning = $this->hmacService->generateHmac('aws4_request', $kService->toString(), HashAlgorithm::SHA256);
|
||||
|
||||
// Calculate signature
|
||||
$signature = $this->hmacService->generateHmac($stringToSign, $kSigning->toString(), HashAlgorithm::SHA256);
|
||||
|
||||
return $signature->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build credential string for presigned URLs
|
||||
*/
|
||||
private function buildCredential(int $timestamp): string
|
||||
{
|
||||
$dateStamp = gmdate('Ymd', $timestamp);
|
||||
$credentialScope = "{$dateStamp}/{$this->region}/" . self::SERVICE_NAME . '/aws4_request';
|
||||
|
||||
return $this->accessKey . '/' . $credentialScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send HTTP request and handle errors
|
||||
*/
|
||||
private function sendRequest(ClientRequest $request): ClientResponse
|
||||
{
|
||||
try {
|
||||
return $this->httpClient->send($request);
|
||||
} catch (\Throwable $e) {
|
||||
throw StorageConnectionException::for($this->endpoint, $e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ETag from response headers
|
||||
*/
|
||||
private function extractEtag(Headers $headers): ?string
|
||||
{
|
||||
$etag = $headers->get('ETag')?->value();
|
||||
if ($etag === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove quotes if present
|
||||
return trim($etag, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content length from response headers
|
||||
*/
|
||||
private function extractSize(Headers $headers): ?int
|
||||
{
|
||||
$contentLength = $headers->get('Content-Length')?->value();
|
||||
if ($contentLength === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $contentLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content type from response headers
|
||||
*/
|
||||
private function extractContentType(Headers $headers): ?string
|
||||
{
|
||||
return $headers->get('Content-Type')?->value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract last modified timestamp from response headers
|
||||
*/
|
||||
private function extractLastModified(Headers $headers): ?int
|
||||
{
|
||||
$lastModified = $headers->get('Last-Modified')?->value();
|
||||
if ($lastModified === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timestamp = strtotime($lastModified);
|
||||
|
||||
return $timestamp !== false ? $timestamp : null;
|
||||
}
|
||||
}
|
||||
|
||||
248
src/Infrastructure/Storage/S3ObjectStorage.php
Normal file
248
src/Infrastructure/Storage/S3ObjectStorage.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\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\ObjectInfo;
|
||||
use App\Framework\Storage\ObjectStorage;
|
||||
use App\Framework\Storage\StreamableObjectStorage;
|
||||
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 App\Framework\Storage\ValueObjects\VersionId;
|
||||
use DateInterval;
|
||||
|
||||
/**
|
||||
* S3-compatible Object Storage implementation using MinIoClient
|
||||
*/
|
||||
final readonly class S3ObjectStorage implements ObjectStorage, StreamableObjectStorage
|
||||
{
|
||||
public function __construct(
|
||||
private MinIoClient $client
|
||||
) {
|
||||
}
|
||||
|
||||
public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo
|
||||
{
|
||||
$bucketName = BucketName::fromString($bucket);
|
||||
$objectKey = ObjectKey::fromString($key);
|
||||
|
||||
$headers = $opts['headers'] ?? [];
|
||||
if (isset($opts['contentType'])) {
|
||||
$headers['Content-Type'] = $opts['contentType'];
|
||||
}
|
||||
|
||||
$result = $this->client->putObject($bucket, $key, $body, $headers);
|
||||
|
||||
// Build Value Objects from result
|
||||
$etag = $result['etag'] !== null ? Hash::fromString($result['etag'], HashAlgorithm::SHA256) : null;
|
||||
$size = $result['size'] !== null ? FileSize::fromBytes($result['size']) : null;
|
||||
|
||||
$contentType = null;
|
||||
if ($result['contentType'] !== null) {
|
||||
$contentType = MimeType::tryFrom($result['contentType']) ?? CustomMimeType::fromString($result['contentType']);
|
||||
} elseif (isset($opts['contentType'])) {
|
||||
$contentType = MimeType::tryFrom($opts['contentType']) ?? CustomMimeType::fromString($opts['contentType']);
|
||||
}
|
||||
|
||||
$metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []);
|
||||
$versionId = isset($opts['versionId']) ? VersionId::fromString($opts['versionId']) : null;
|
||||
$lastModified = isset($result['lastModified']) && $result['lastModified'] !== null
|
||||
? Timestamp::fromTimestamp($result['lastModified'])
|
||||
: null;
|
||||
|
||||
return new ObjectInfo(
|
||||
bucket: $bucketName,
|
||||
key: $objectKey,
|
||||
etag: $etag,
|
||||
size: $size,
|
||||
contentType: $contentType,
|
||||
lastModified: $lastModified,
|
||||
metadata: $metadata,
|
||||
versionId: $versionId
|
||||
);
|
||||
}
|
||||
|
||||
public function get(string $bucket, string $key): string
|
||||
{
|
||||
try {
|
||||
return $this->client->getObject($bucket, $key);
|
||||
} catch (StorageOperationException $e) {
|
||||
if ($e->getCode() === 404) {
|
||||
throw ObjectNotFoundException::for($bucket, $key, $e);
|
||||
}
|
||||
|
||||
throw $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
|
||||
{
|
||||
if (! is_resource($destination)) {
|
||||
throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->client->getObjectToStream($bucket, $key, $destination, $opts);
|
||||
} catch (StorageOperationException $e) {
|
||||
if ($e->getCode() === 404) {
|
||||
throw ObjectNotFoundException::for($bucket, $key, $e);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo
|
||||
{
|
||||
if (! is_resource($source)) {
|
||||
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream');
|
||||
}
|
||||
|
||||
$bucketName = BucketName::fromString($bucket);
|
||||
$objectKey = ObjectKey::fromString($key);
|
||||
|
||||
$headers = $opts['headers'] ?? [];
|
||||
if (isset($opts['contentType'])) {
|
||||
$headers['Content-Type'] = $opts['contentType'];
|
||||
}
|
||||
|
||||
$clientOpts = [
|
||||
'headers' => $headers,
|
||||
'contentLength' => $opts['contentLength'] ?? null,
|
||||
];
|
||||
|
||||
$result = $this->client->putObjectFromStream($bucket, $key, $source, $clientOpts);
|
||||
|
||||
// Build Value Objects from result
|
||||
$etag = $result['etag'] !== null ? Hash::fromString($result['etag'], HashAlgorithm::SHA256) : null;
|
||||
$size = $result['size'] !== null ? FileSize::fromBytes($result['size']) : null;
|
||||
|
||||
$contentType = null;
|
||||
if ($result['contentType'] !== null) {
|
||||
$contentType = MimeType::tryFrom($result['contentType']) ?? CustomMimeType::fromString($result['contentType']);
|
||||
} elseif (isset($opts['contentType'])) {
|
||||
$contentType = MimeType::tryFrom($opts['contentType']) ?? CustomMimeType::fromString($opts['contentType']);
|
||||
}
|
||||
|
||||
$metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []);
|
||||
$versionId = isset($opts['versionId']) ? VersionId::fromString($opts['versionId']) : null;
|
||||
$lastModified = isset($result['lastModified']) && $result['lastModified'] !== null
|
||||
? Timestamp::fromTimestamp($result['lastModified'])
|
||||
: null;
|
||||
|
||||
return new ObjectInfo(
|
||||
bucket: $bucketName,
|
||||
key: $objectKey,
|
||||
etag: $etag,
|
||||
size: $size,
|
||||
contentType: $contentType,
|
||||
lastModified: $lastModified,
|
||||
metadata: $metadata,
|
||||
versionId: $versionId
|
||||
);
|
||||
}
|
||||
|
||||
public function openReadStream(string $bucket, string $key)
|
||||
{
|
||||
// For S3, we create a temporary stream and stream the content into it
|
||||
// This is necessary because S3 doesn't provide direct stream access
|
||||
// (would require a custom stream wrapper for true streaming)
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
if ($stream === false) {
|
||||
throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->client->getObjectToStream($bucket, $key, $stream);
|
||||
rewind($stream);
|
||||
|
||||
return $stream;
|
||||
} catch (StorageOperationException $e) {
|
||||
fclose($stream);
|
||||
if ($e->getCode() === 404) {
|
||||
throw ObjectNotFoundException::for($bucket, $key, $e);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function head(string $bucket, string $key): ObjectInfo
|
||||
{
|
||||
try {
|
||||
$bucketName = BucketName::fromString($bucket);
|
||||
$objectKey = ObjectKey::fromString($key);
|
||||
$result = $this->client->headObject($bucket, $key);
|
||||
|
||||
// Build Value Objects from result
|
||||
$etag = $result['etag'] !== null ? Hash::fromString($result['etag'], HashAlgorithm::SHA256) : null;
|
||||
$size = $result['size'] !== null ? FileSize::fromBytes($result['size']) : null;
|
||||
|
||||
$contentType = null;
|
||||
if ($result['contentType'] !== null) {
|
||||
$contentType = MimeType::tryFrom($result['contentType']) ?? CustomMimeType::fromString($result['contentType']);
|
||||
}
|
||||
|
||||
$lastModified = $result['lastModified'] !== null
|
||||
? Timestamp::fromTimestamp($result['lastModified'])
|
||||
: null;
|
||||
|
||||
return new ObjectInfo(
|
||||
bucket: $bucketName,
|
||||
key: $objectKey,
|
||||
etag: $etag,
|
||||
size: $size,
|
||||
contentType: $contentType,
|
||||
lastModified: $lastModified,
|
||||
metadata: ObjectMetadata::empty(),
|
||||
versionId: null
|
||||
);
|
||||
} catch (StorageOperationException $e) {
|
||||
if ($e->getCode() === 404) {
|
||||
throw ObjectNotFoundException::for($bucket, $key, $e);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(string $bucket, string $key): void
|
||||
{
|
||||
$this->client->deleteObject($bucket, $key);
|
||||
}
|
||||
|
||||
public function exists(string $bucket, string $key): bool
|
||||
{
|
||||
return $this->client->objectExists($bucket, $key);
|
||||
}
|
||||
|
||||
public function url(string $bucket, string $key): ?string
|
||||
{
|
||||
// S3 public URLs are only available if bucket is public
|
||||
// For now, return null (can be extended later)
|
||||
return null;
|
||||
}
|
||||
|
||||
public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string
|
||||
{
|
||||
return $this->client->createPresignedUrl($bucket, $key, $ttl);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user