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:
2025-11-04 20:39:48 +01:00
parent 700fe8118b
commit 3ed2685e74
80 changed files with 9891 additions and 850 deletions

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

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

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

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

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

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

View 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

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

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

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

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

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

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

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

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

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

View File

@@ -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:

View File

@@ -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

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

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