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