chore: complete update
This commit is contained in:
124
src/Infrastructure/AI/AiHandlerFactory.php
Normal file
124
src/Infrastructure/AI/AiHandlerFactory.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\AI;
|
||||
|
||||
use App\Domain\AI\AiModel;
|
||||
use App\Domain\AI\AiProvider;
|
||||
use App\Domain\AI\AiQueryHandlerInterface;
|
||||
use App\Infrastructure\AI\GPT4All\Gpt4AllQueryHandler;
|
||||
use App\Infrastructure\AI\Ollama\OllamaQueryHandler;
|
||||
use App\Infrastructure\AI\OpenAI\OpenAiQueryHandler;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class AiHandlerFactory
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClient $httpClient,
|
||||
private string $openAiApiKey = '',
|
||||
private string $gpt4AllApiUrl = 'http://host.docker.internal:4891',
|
||||
private string $ollamaApiUrl = 'http://host.docker.internal:11434'
|
||||
) {}
|
||||
|
||||
public function createForModel(AiModel $model): AiQueryHandlerInterface
|
||||
{
|
||||
return match($model->getProvider()) {
|
||||
AiProvider::OPENAI => new OpenAiQueryHandler(
|
||||
$this->httpClient,
|
||||
$this->openAiApiKey
|
||||
),
|
||||
AiProvider::GPT4ALL => new Gpt4AllQueryHandler(
|
||||
$this->httpClient,
|
||||
$this->gpt4AllApiUrl
|
||||
),
|
||||
AiProvider::OLLAMA => new OllamaQueryHandler(
|
||||
$this->httpClient,
|
||||
$this->ollamaApiUrl
|
||||
),
|
||||
default => throw new InvalidArgumentException("Unsupported AI provider: {$model->getProvider()->value}")
|
||||
};
|
||||
}
|
||||
|
||||
public function createForProvider(AiProvider $provider): AiQueryHandlerInterface
|
||||
{
|
||||
return match($provider) {
|
||||
AiProvider::OPENAI => new OpenAiQueryHandler(
|
||||
$this->httpClient,
|
||||
$this->openAiApiKey
|
||||
),
|
||||
AiProvider::GPT4ALL => new Gpt4AllQueryHandler(
|
||||
$this->httpClient,
|
||||
$this->gpt4AllApiUrl
|
||||
),
|
||||
AiProvider::OLLAMA => new OllamaQueryHandler(
|
||||
$this->httpClient,
|
||||
$this->ollamaApiUrl
|
||||
),
|
||||
default => throw new InvalidArgumentException("Unsupported AI provider: {$provider->value}")
|
||||
};
|
||||
}
|
||||
|
||||
public function getAvailableProviders(): array
|
||||
{
|
||||
$providers = [];
|
||||
|
||||
// OpenAI ist verfügbar wenn API Key gesetzt ist
|
||||
if (!empty($this->openAiApiKey)) {
|
||||
$providers[] = AiProvider::OPENAI;
|
||||
}
|
||||
|
||||
// GPT4All prüfen
|
||||
$gpt4allHandler = new Gpt4AllQueryHandler($this->httpClient, $this->gpt4AllApiUrl);
|
||||
if ($gpt4allHandler->isAvailable()) {
|
||||
$providers[] = AiProvider::GPT4ALL;
|
||||
}
|
||||
|
||||
// Ollama prüfen
|
||||
$ollamaHandler = new OllamaQueryHandler($this->httpClient, $this->ollamaApiUrl);
|
||||
if ($ollamaHandler->isAvailable()) {
|
||||
$providers[] = AiProvider::OLLAMA;
|
||||
}
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
public function getAvailableModels(): array
|
||||
{
|
||||
$availableProviders = $this->getAvailableProviders();
|
||||
$providerSet = array_flip($availableProviders);
|
||||
$availableModels = [];
|
||||
|
||||
foreach (AiModel::cases() as $model) {
|
||||
if (isset($providerSet[$model->getProvider()->value])) {
|
||||
// Für Ollama zusätzlich prüfen ob das spezifische Modell verfügbar ist
|
||||
if ($model->getProvider() === AiProvider::OLLAMA) {
|
||||
$ollamaHandler = new OllamaQueryHandler($this->httpClient, $this->ollamaApiUrl);
|
||||
$ollamaModels = $ollamaHandler->getAvailableModels();
|
||||
if (in_array($model->value, $ollamaModels, true)) {
|
||||
$availableModels[] = $model;
|
||||
}
|
||||
} else {
|
||||
$availableModels[] = $model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $availableModels;
|
||||
}
|
||||
|
||||
public function isProviderAvailable(AiProvider $provider): bool
|
||||
{
|
||||
return in_array($provider, $this->getAvailableProviders(), true);
|
||||
}
|
||||
|
||||
public function getOllamaAvailableModels(): array
|
||||
{
|
||||
if (!$this->isProviderAvailable(AiProvider::OLLAMA)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ollamaHandler = new OllamaQueryHandler($this->httpClient, $this->ollamaApiUrl);
|
||||
return $ollamaHandler->getAvailableModels();
|
||||
}
|
||||
}
|
||||
90
src/Infrastructure/AI/AiService.php
Normal file
90
src/Infrastructure/AI/AiService.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\AI;
|
||||
|
||||
use App\Domain\AI\AiModel;
|
||||
use App\Domain\AI\AiProvider;
|
||||
use App\Domain\AI\AiQuery;
|
||||
use App\Domain\AI\AiResponse;
|
||||
use App\Domain\AI\Exception\AiProviderUnavailableException;
|
||||
|
||||
final readonly class AiService
|
||||
{
|
||||
public function __construct(
|
||||
private AiHandlerFactory $handlerFactory
|
||||
) {}
|
||||
|
||||
public function query(
|
||||
string $message,
|
||||
AiModel $model = AiModel::GPT_35_TURBO,
|
||||
array $messages = [],
|
||||
float $temperature = 0.7,
|
||||
?int $maxTokens = null
|
||||
): AiResponse {
|
||||
$query = new AiQuery($message, $model, $messages, $temperature, $maxTokens);
|
||||
$handler = $this->handlerFactory->createForModel($model);
|
||||
|
||||
return $handler($query);
|
||||
}
|
||||
|
||||
public function queryWithFallback(
|
||||
string $message,
|
||||
AiModel $preferredModel = AiModel::GPT_35_TURBO,
|
||||
array $messages = [],
|
||||
float $temperature = 0.7,
|
||||
?int $maxTokens = null
|
||||
): AiResponse {
|
||||
// Zuerst bevorzugtes Modell versuchen
|
||||
try {
|
||||
return $this->query($message, $preferredModel, $messages, $temperature, $maxTokens);
|
||||
} catch (AiProviderUnavailableException $e) {
|
||||
// Fallback auf verfügbare Modelle
|
||||
$availableModels = $this->handlerFactory->getAvailableModels();
|
||||
|
||||
if (empty($availableModels)) {
|
||||
throw new AiProviderUnavailableException(
|
||||
$preferredModel->getProvider(),
|
||||
'Keine AI-Provider verfügbar. Prüfen Sie Ihre Konfiguration.'
|
||||
);
|
||||
}
|
||||
|
||||
// Erstes verfügbares Modell verwenden
|
||||
$fallbackModel = $availableModels[0];
|
||||
return $this->query($message, $fallbackModel, $messages, $temperature, $maxTokens);
|
||||
}
|
||||
}
|
||||
|
||||
public function queryWithConversation(
|
||||
array $messages,
|
||||
AiModel $model = AiModel::GPT_35_TURBO,
|
||||
float $temperature = 0.7,
|
||||
?int $maxTokens = null
|
||||
): AiResponse {
|
||||
// Für Gespräche verwenden wir eine leere Nachricht, da die messages bereits alles enthalten
|
||||
$query = new AiQuery('', $model, $messages, $temperature, $maxTokens);
|
||||
$handler = $this->handlerFactory->createForModel($model);
|
||||
|
||||
return $handler($query);
|
||||
}
|
||||
|
||||
public function getAvailableProviders(): array
|
||||
{
|
||||
return $this->handlerFactory->getAvailableProviders();
|
||||
}
|
||||
|
||||
public function getAvailableModels(): array
|
||||
{
|
||||
return $this->handlerFactory->getAvailableModels();
|
||||
}
|
||||
|
||||
public function isProviderAvailable(AiProvider $provider): bool
|
||||
{
|
||||
return $this->handlerFactory->isProviderAvailable($provider);
|
||||
}
|
||||
|
||||
public function getOllamaAvailableModels(): array
|
||||
{
|
||||
return $this->handlerFactory->getOllamaAvailableModels();
|
||||
}
|
||||
}
|
||||
89
src/Infrastructure/AI/GPT4All/Gpt4AllQueryHandler.php
Normal file
89
src/Infrastructure/AI/GPT4All/Gpt4AllQueryHandler.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\AI\GPT4All;
|
||||
|
||||
use App\Domain\AI\AiQuery;
|
||||
use App\Domain\AI\AiQueryHandlerInterface;
|
||||
use App\Domain\AI\AiResponse;
|
||||
use App\Domain\AI\Exception\AiProviderUnavailableException;
|
||||
use App\Domain\AI\AiProvider;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\HttpClient\ClientRequest;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
use App\Framework\HttpClient\Exception\CurlExecutionFailed;
|
||||
|
||||
final readonly class Gpt4AllQueryHandler implements AiQueryHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClient $httpClient,
|
||||
private string $gpt4AllApiUrl = 'http://host.docker.internal:4891'
|
||||
) {}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
try {
|
||||
$healthUrl = $this->gpt4AllApiUrl . '/health';
|
||||
$request = ClientRequest::json(Method::GET, $healthUrl, []);
|
||||
|
||||
$this->httpClient->send($request);
|
||||
return true;
|
||||
} catch (CurlExecutionFailed $e) {
|
||||
return false;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function __invoke(AiQuery $query): AiResponse
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
throw new AiProviderUnavailableException(
|
||||
AiProvider::GPT4ALL,
|
||||
"GPT4All Server ist nicht erreichbar unter {$this->gpt4AllApiUrl}. " .
|
||||
"Stellen Sie sicher, dass GPT4All läuft oder verwenden Sie einen anderen Provider."
|
||||
);
|
||||
}
|
||||
|
||||
$url = $this->gpt4AllApiUrl . '/v1/chat/completions';
|
||||
|
||||
$data = [
|
||||
'model' => $query->model->value,
|
||||
'messages' => $query->messages !== []
|
||||
? $query->messages
|
||||
: [['role' => 'user', 'content' => $query->message]],
|
||||
'temperature' => $query->temperature,
|
||||
];
|
||||
|
||||
if ($query->maxTokens !== null) {
|
||||
$data['max_tokens'] = $query->maxTokens;
|
||||
}
|
||||
|
||||
try {
|
||||
$request = ClientRequest::json(
|
||||
Method::POST,
|
||||
$url,
|
||||
$data
|
||||
)->with([
|
||||
'headers' => new Headers()
|
||||
->with('Content-Type', 'application/json')
|
||||
]);
|
||||
|
||||
$response = $this->httpClient->send($request);
|
||||
$body = json_decode($response->body, true);
|
||||
|
||||
return new AiResponse(
|
||||
content: $body['choices'][0]['message']['content'] ?? '',
|
||||
provider: 'gpt4all',
|
||||
model: $query->model->value,
|
||||
tokensUsed: $body['usage']['total_tokens'] ?? null
|
||||
);
|
||||
} catch (CurlExecutionFailed $e) {
|
||||
throw new AiProviderUnavailableException(
|
||||
AiProvider::GPT4ALL,
|
||||
"Verbindung zu GPT4All fehlgeschlagen: " . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
140
src/Infrastructure/AI/Ollama/OllamaQueryHandler.php
Normal file
140
src/Infrastructure/AI/Ollama/OllamaQueryHandler.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\AI\Ollama;
|
||||
|
||||
use App\Domain\AI\AiQuery;
|
||||
use App\Domain\AI\AiQueryHandlerInterface;
|
||||
use App\Domain\AI\AiResponse;
|
||||
use App\Domain\AI\Exception\AiProviderUnavailableException;
|
||||
use App\Domain\AI\AiProvider;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\HttpClient\ClientOptions;
|
||||
use App\Framework\HttpClient\ClientRequest;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
use App\Framework\HttpClient\Exception\CurlExecutionFailed;
|
||||
|
||||
final readonly class OllamaQueryHandler implements AiQueryHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClient $httpClient,
|
||||
private string $ollamaApiUrl = 'http://host.docker.internal:11434'
|
||||
) {}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
#debug(file_get_contents('http://host.docker.internal:11434/api/tags'));
|
||||
|
||||
try {
|
||||
$healthUrl = $this->ollamaApiUrl . '/api/tags';
|
||||
$request = new ClientRequest(
|
||||
method: Method::GET,
|
||||
url: $healthUrl,
|
||||
headers: new Headers(),
|
||||
body: '',
|
||||
options: new ClientOptions(
|
||||
timeout : 360,
|
||||
verifySsl : false,
|
||||
connectTimeout: 60
|
||||
)
|
||||
);
|
||||
|
||||
$this->httpClient->send($request);
|
||||
|
||||
return true;
|
||||
} catch (CurlExecutionFailed $e) {
|
||||
return false;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function getAvailableModels(): array
|
||||
{
|
||||
try {
|
||||
$tagsUrl = $this->ollamaApiUrl . '/api/tags';
|
||||
$request = ClientRequest::json(Method::GET, $tagsUrl, []);
|
||||
|
||||
$response = $this->httpClient->send($request);
|
||||
$body = json_decode($response->body, true);
|
||||
|
||||
return array_map(fn($model) => $model['name'], $body['models'] ?? []);
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function __invoke(AiQuery $query): AiResponse
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
throw new AiProviderUnavailableException(
|
||||
AiProvider::OLLAMA,
|
||||
"Ollama Server ist nicht erreichbar unter {$this->ollamaApiUrl}. " .
|
||||
"Stellen Sie sicher, dass Ollama läuft oder verwenden Sie einen anderen Provider."
|
||||
);
|
||||
}
|
||||
|
||||
// Prüfen ob das Modell verfügbar ist
|
||||
$availableModels = $this->getAvailableModels();
|
||||
$modelExists = in_array($query->model->value, $availableModels, true);
|
||||
|
||||
if (!$modelExists) {
|
||||
throw new AiProviderUnavailableException(
|
||||
AiProvider::OLLAMA,
|
||||
"Modell '{$query->model->value}' ist nicht verfügbar. " .
|
||||
"Verfügbare Modelle: " . implode(', ', $availableModels) . ". " .
|
||||
"Installieren Sie das Modell mit: ollama pull {$query->model->value}"
|
||||
);
|
||||
}
|
||||
|
||||
$url = $this->ollamaApiUrl . '/api/chat';
|
||||
|
||||
// Ollama verwendet ein anderes Format als OpenAI
|
||||
$messages = $query->messages !== []
|
||||
? $query->messages
|
||||
: [['role' => 'user', 'content' => $query->message]];
|
||||
|
||||
$data = [
|
||||
'model' => $query->model->value,
|
||||
'messages' => $messages,
|
||||
'stream' => false, // Keine Streaming-Antwort
|
||||
'options' => [
|
||||
'temperature' => $query->temperature,
|
||||
]
|
||||
];
|
||||
|
||||
if ($query->maxTokens !== null) {
|
||||
$data['options']['num_predict'] = $query->maxTokens;
|
||||
}
|
||||
|
||||
try {
|
||||
$request = ClientRequest::json(
|
||||
Method::POST,
|
||||
$url,
|
||||
$data
|
||||
)->with([
|
||||
'headers' => new Headers()
|
||||
->with('Content-Type', 'application/json')
|
||||
]);
|
||||
|
||||
$response = $this->httpClient->send($request);
|
||||
$body = json_decode($response->body, true);
|
||||
|
||||
// Ollama API Antwortformat ist anders als OpenAI
|
||||
$content = $body['message']['content'] ?? '';
|
||||
|
||||
return new AiResponse(
|
||||
content: $content,
|
||||
provider: 'ollama',
|
||||
model: $query->model->value,
|
||||
tokensUsed: $body['eval_count'] ?? null // Ollama verwendet eval_count für Token-Zählung
|
||||
);
|
||||
} catch (CurlExecutionFailed $e) {
|
||||
throw new AiProviderUnavailableException(
|
||||
AiProvider::OLLAMA,
|
||||
"Verbindung zu Ollama fehlgeschlagen: " . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/Infrastructure/AI/OpenAI/OpenAiQueryHandler.php
Normal file
56
src/Infrastructure/AI/OpenAI/OpenAiQueryHandler.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\AI\OpenAI;
|
||||
|
||||
use App\Domain\AI\AiQuery;
|
||||
use App\Domain\AI\AiQueryHandlerInterface;
|
||||
use App\Domain\AI\AiResponse;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\HttpClient\ClientRequest;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
final readonly class OpenAiQueryHandler implements AiQueryHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClient $httpClient,
|
||||
private string $openAiApiKey = ""
|
||||
) {}
|
||||
|
||||
public function __invoke(AiQuery $query): AiResponse
|
||||
{
|
||||
$url = 'https://api.openai.com/v1/chat/completions';
|
||||
|
||||
$data = [
|
||||
'model' => $query->model->value,
|
||||
'messages' => $query->messages !== []
|
||||
? $query->messages
|
||||
: [['role' => 'user', 'content' => $query->message]],
|
||||
'temperature' => $query->temperature,
|
||||
];
|
||||
|
||||
if ($query->maxTokens !== null) {
|
||||
$data['max_tokens'] = $query->maxTokens;
|
||||
}
|
||||
|
||||
$request = ClientRequest::json(
|
||||
Method::POST,
|
||||
$url,
|
||||
$data
|
||||
)->with([
|
||||
'headers' => new Headers()
|
||||
->with('Authorization', 'Bearer ' . $this->openAiApiKey)
|
||||
]);
|
||||
|
||||
$response = $this->httpClient->send($request);
|
||||
$body = json_decode($response->body, true);
|
||||
|
||||
return new AiResponse(
|
||||
content: $body['choices'][0]['message']['content'] ?? '',
|
||||
provider: 'openai',
|
||||
model: $query->model->value,
|
||||
tokensUsed: $body['usage']['total_tokens'] ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
77
src/Infrastructure/Api/GitHubClient.php
Normal file
77
src/Infrastructure/Api/GitHubClient.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api;
|
||||
|
||||
use App\Framework\Api\ApiRequestTrait;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\HttpClient\AuthConfig;
|
||||
use App\Framework\HttpClient\ClientOptions;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
final class GitHubClient
|
||||
{
|
||||
use ApiRequestTrait;
|
||||
|
||||
public function __construct(
|
||||
string $token,
|
||||
?HttpClient $httpClient = null
|
||||
) {
|
||||
$this->baseUrl = 'https://api.github.com';
|
||||
$this->defaultOptions = new ClientOptions(
|
||||
auth: AuthConfig::bearer($token)
|
||||
);
|
||||
$this->httpClient = $httpClient ?? new CurlHttpClient();
|
||||
|
||||
// GitHub API spezifische Header setzen
|
||||
$this->defaultOptions = new ClientOptions(
|
||||
auth: AuthConfig::bearer($token),
|
||||
userAgent: 'PHP-App-Framework'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Repositories eines Benutzers
|
||||
*/
|
||||
public function getUserRepositories(string $username): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: "users/{$username}/repos"
|
||||
);
|
||||
|
||||
return $this->decodeJson($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein neues Repository
|
||||
*/
|
||||
public function createRepository(string $name, string $description = '', bool $isPrivate = false): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::POST,
|
||||
endpoint: 'user/repos',
|
||||
data: [
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'private' => $isPrivate
|
||||
]
|
||||
);
|
||||
|
||||
return $this->decodeJson($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft Informationen zu einem bestimmten Repository ab
|
||||
*/
|
||||
public function getRepository(string $owner, string $repo): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: "repos/{$owner}/{$repo}"
|
||||
);
|
||||
|
||||
return $this->decodeJson($response);
|
||||
}
|
||||
}
|
||||
95
src/Infrastructure/Api/README.md
Normal file
95
src/Infrastructure/Api/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# API-Client Framework
|
||||
|
||||
Diese Komponente bietet eine einheitliche Schnittstelle für die Kommunikation mit verschiedenen externen APIs.
|
||||
|
||||
## Verfügbare API-Clients
|
||||
|
||||
### RapidMailClient
|
||||
|
||||
Integration mit der RapidMail API für E-Mail-Marketing:
|
||||
|
||||
```php
|
||||
$client = new RapidMailClient(
|
||||
ApiConfig::RAPIDMAIL_USERNAME->value,
|
||||
ApiConfig::RAPIDMAIL_PASSWORD->value
|
||||
);
|
||||
|
||||
// Abonnent zur Liste hinzufügen
|
||||
$result = $client->addRecipient('user@example.com', ApiConfig::getRapidmailListId());
|
||||
```
|
||||
|
||||
### ShopifyClient
|
||||
|
||||
Integration mit der Shopify REST API für E-Commerce:
|
||||
|
||||
```php
|
||||
$client = new ShopifyClient(
|
||||
ApiConfig::SHOPIFY_SHOP_DOMAIN->value,
|
||||
ApiConfig::SHOPIFY_ACCESS_TOKEN->value,
|
||||
ApiConfig::SHOPIFY_API_VERSION->value
|
||||
);
|
||||
|
||||
// Produkte abrufen
|
||||
$products = $client->getProducts(['limit' => 50]);
|
||||
|
||||
// Einzelnes Produkt abrufen
|
||||
$product = $client->getProduct(12345678);
|
||||
```
|
||||
|
||||
### GitHubClient
|
||||
|
||||
Integration mit der GitHub API:
|
||||
|
||||
```php
|
||||
$client = new GitHubClient('github_personal_access_token');
|
||||
|
||||
// Repository-Informationen abrufen
|
||||
$repo = $client->getRepository('username', 'repo-name');
|
||||
```
|
||||
|
||||
## Implementierung eines neuen API-Clients
|
||||
|
||||
Neue API-Clients können einfach durch Verwendung des `ApiRequestTrait` erstellt werden:
|
||||
|
||||
```php
|
||||
use App\Framework\Api\ApiRequestTrait;
|
||||
|
||||
final class NewApiClient
|
||||
{
|
||||
use ApiRequestTrait;
|
||||
|
||||
public function __construct(string $apiKey)
|
||||
{
|
||||
$this->baseUrl = 'https://api.example.com/v1';
|
||||
$this->defaultOptions = new ClientOptions(
|
||||
auth: AuthConfig::bearer($apiKey)
|
||||
);
|
||||
$this->httpClient = new CurlHttpClient();
|
||||
}
|
||||
|
||||
public function getSomeResource(): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: HttpMethod::GET,
|
||||
endpoint: 'resources'
|
||||
);
|
||||
|
||||
return $this->decodeJson($response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
Alle API-Clients werfen eine standardisierte `ApiException` bei Fehlern:
|
||||
|
||||
```php
|
||||
try {
|
||||
$result = $client->getSomeResource();
|
||||
} catch (ApiException $e) {
|
||||
// Zugriff auf Details des Fehlers
|
||||
$statusCode = $e->getCode();
|
||||
$message = $e->getMessage();
|
||||
$responseData = $e->getResponseData();
|
||||
}
|
||||
```
|
||||
39
src/Infrastructure/Api/RapidMail/BlacklistService.php
Normal file
39
src/Infrastructure/Api/RapidMail/BlacklistService.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Api\RapidMail;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
|
||||
final readonly class BlacklistService
|
||||
{
|
||||
public function __construct(
|
||||
private RapidMailApiClient $apiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Holt die Blacklist
|
||||
*/
|
||||
public function getAll(int $page = 1, int $perPage = 50): array
|
||||
{
|
||||
return $this->apiClient->request(Method::GET, 'blacklist', [], [
|
||||
'page' => $page,
|
||||
'per_page' => $perPage
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt eine E-Mail zur Blacklist hinzu
|
||||
*/
|
||||
public function add(string $email): array
|
||||
{
|
||||
return $this->apiClient->request(Method::POST, 'blacklist', ['email' => $email]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt eine E-Mail von der Blacklist
|
||||
*/
|
||||
public function remove(int $blacklistId): void
|
||||
{
|
||||
$this->apiClient->sendRawRequest(Method::DELETE, "blacklist/{$blacklistId}");
|
||||
}
|
||||
}
|
||||
35
src/Infrastructure/Api/RapidMail/Mailing.php
Normal file
35
src/Infrastructure/Api/RapidMail/Mailing.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Api\RapidMail;
|
||||
|
||||
final readonly class Mailing
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $id = null,
|
||||
public ?string $name = null,
|
||||
public ?string $subject = null,
|
||||
public ?string $status = null,
|
||||
public ?string $type = null,
|
||||
public ?int $recipientlistId = null,
|
||||
public ?string $createdAt = null,
|
||||
public ?string $updatedAt = null,
|
||||
public ?string $sentAt = null,
|
||||
public ?array $links = null
|
||||
) {}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: $data['id'] ?? null,
|
||||
name: $data['name'] ?? null,
|
||||
subject: $data['subject'] ?? null,
|
||||
status: $data['status'] ?? null,
|
||||
type: $data['type'] ?? null,
|
||||
recipientlistId: $data['recipientlist_id'] ?? null,
|
||||
createdAt: $data['created'] ?? null,
|
||||
updatedAt: $data['updated'] ?? null,
|
||||
sentAt: $data['sent'] ?? null,
|
||||
links: $data['_links'] ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
79
src/Infrastructure/Api/RapidMail/MailingService.php
Normal file
79
src/Infrastructure/Api/RapidMail/MailingService.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Api\RapidMail;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
|
||||
final readonly class MailingService
|
||||
{
|
||||
public function __construct(
|
||||
private RapidMailApiClient $apiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Holt alle Mailings
|
||||
*/
|
||||
public function getAll(array $filter = [], int $page = 1, int $perPage = 50): array
|
||||
{
|
||||
$queryParams = [
|
||||
'page' => $page,
|
||||
'per_page' => $perPage
|
||||
];
|
||||
|
||||
if (!empty($filter)) {
|
||||
$queryParams['filter'] = $filter;
|
||||
}
|
||||
|
||||
$data = $this->apiClient->request(Method::GET, 'mailings', [], $queryParams);
|
||||
|
||||
$mailings = $data['_embedded']['mailings'] ?? [];
|
||||
|
||||
return array_map(
|
||||
fn(array $item) => Mailing::fromArray($item),
|
||||
$mailings
|
||||
);
|
||||
}
|
||||
|
||||
public function getAllWithPagination(array $filter = [], int $page = 1, int $perPage = 50): array
|
||||
{
|
||||
$queryParams = [
|
||||
'page' => $page,
|
||||
'per_page' => $perPage
|
||||
];
|
||||
|
||||
if (!empty($filter)) {
|
||||
$queryParams = array_merge($queryParams, $filter);
|
||||
}
|
||||
|
||||
$apiResponse = $this->apiClient->request(Method::GET, 'mailings', [], $queryParams);
|
||||
|
||||
$mailings = $apiResponse['_embedded']['mailings'] ?? [];
|
||||
|
||||
return [
|
||||
'mailings' => array_map(
|
||||
fn(array $item) => Mailing::fromArray($item),
|
||||
$mailings
|
||||
),
|
||||
'page' => $apiResponse['page'] ?? $page,
|
||||
'page_count' => $apiResponse['page_count'] ?? 1,
|
||||
'total_count' => $apiResponse['total_count'] ?? count($mailings)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt ein spezifisches Mailing
|
||||
*/
|
||||
public function get(int $mailingId): Mailing
|
||||
{
|
||||
$data = $this->apiClient->request(Method::GET, "mailings/{$mailingId}");
|
||||
return Mailing::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet ein Mailing
|
||||
*/
|
||||
public function send(int $mailingId): array
|
||||
{
|
||||
return $this->apiClient->request(Method::POST, "mailings/{$mailingId}/send");
|
||||
}
|
||||
}
|
||||
150
src/Infrastructure/Api/RapidMail/RapidMailApiClient.php
Normal file
150
src/Infrastructure/Api/RapidMail/RapidMailApiClient.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Api\RapidMail;
|
||||
|
||||
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 RapidMailApiClient
|
||||
{
|
||||
private ClientOptions $defaultOptions;
|
||||
|
||||
public function __construct(
|
||||
private RapidMailConfig $config,
|
||||
private HttpClient $httpClient
|
||||
) {
|
||||
$this->defaultOptions = new ClientOptions(
|
||||
timeout: $this->config->timeout,
|
||||
auth: AuthConfig::basic($this->config->username, $this->config->password)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
$url = $this->config->baseUrl . '/' . ltrim($endpoint, '/');
|
||||
|
||||
$options = $this->defaultOptions;
|
||||
if (!empty($queryParams)) {
|
||||
$options = $options->with(['query' => $queryParams]);
|
||||
}
|
||||
|
||||
if (in_array($method, [Method::GET, Method::DELETE]) && !empty($data)) {
|
||||
$existingQuery = $options->query;
|
||||
$options = $options->with(['query' => array_merge($existingQuery, $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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Query-Parameter
|
||||
*/
|
||||
public function getDefaultQueryParams(): array
|
||||
{
|
||||
return [
|
||||
'send_activationmail' => $this->config->sendActivationMail ? 'yes' : 'no',
|
||||
'test_mode' => $this->config->testMode ? 'yes' : 'no'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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['detail'])) {
|
||||
$message = 'RapidMail API Error: ' . $responseData['detail'];
|
||||
|
||||
if (isset($responseData['validation_messages'])) {
|
||||
$message .= ' - Validation: ' . json_encode(
|
||||
$responseData['validation_messages'],
|
||||
JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
if (isset($responseData['error'])) {
|
||||
return 'RapidMail API Error: ' . $responseData['error'];
|
||||
}
|
||||
|
||||
if (isset($responseData['message'])) {
|
||||
return 'RapidMail API Error: ' . $responseData['message'];
|
||||
}
|
||||
|
||||
return "RapidMail API Error (HTTP {$response->status->value}): " .
|
||||
substr($response->body, 0, 200);
|
||||
}
|
||||
}
|
||||
27
src/Infrastructure/Api/RapidMail/RapidMailClient.php
Normal file
27
src/Infrastructure/Api/RapidMail/RapidMailClient.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Api\RapidMail;
|
||||
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
final readonly class RapidMailClient
|
||||
{
|
||||
public RecipientService $recipients;
|
||||
public RecipientListService $recipientLists;
|
||||
public MailingService $mailings;
|
||||
public StatisticsService $statistics;
|
||||
public BlacklistService $blacklist;
|
||||
|
||||
public function __construct(
|
||||
RapidMailConfig $config,
|
||||
HttpClient $httpClient
|
||||
) {
|
||||
$apiClient = new RapidMailApiClient($config, $httpClient);
|
||||
|
||||
$this->recipients = new RecipientService($apiClient);
|
||||
$this->recipientLists = new RecipientListService($apiClient);
|
||||
$this->mailings = new MailingService($apiClient);
|
||||
$this->statistics = new StatisticsService($apiClient);
|
||||
$this->blacklist = new BlacklistService($apiClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Api\RapidMail;
|
||||
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
|
||||
final readonly class RapidMailClientInitializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function __invoke(): RapidMailClient
|
||||
{
|
||||
return new RapidMailClient(
|
||||
new RapidMailConfig(
|
||||
username: '3f60a5c15c3d49c631d0e75b7c1090a3859423a7',
|
||||
password: '572d25dc36e620f14c89e9c75c02c1f3794ba3c0',
|
||||
|
||||
testMode: true,
|
||||
),
|
||||
new CurlHttpClient()
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/Infrastructure/Api/RapidMail/RapidMailConfig.php
Normal file
15
src/Infrastructure/Api/RapidMail/RapidMailConfig.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Api\RapidMail;
|
||||
|
||||
final readonly class RapidMailConfig
|
||||
{
|
||||
public function __construct(
|
||||
public string $username,
|
||||
public string $password,
|
||||
public string $baseUrl = 'https://apiv3.emailsys.net',
|
||||
public bool $testMode = false,
|
||||
public bool $sendActivationMail = true,
|
||||
public float $timeout = 30.0
|
||||
) {}
|
||||
}
|
||||
30
src/Infrastructure/Api/RapidMail/RecipientId.php
Normal file
30
src/Infrastructure/Api/RapidMail/RecipientId.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Api\RapidMail;
|
||||
|
||||
final readonly class RecipientId
|
||||
{
|
||||
public function __construct(
|
||||
public int $value,
|
||||
)
|
||||
{
|
||||
if ($value <= 0) {
|
||||
throw new \InvalidArgumentException('RecipientId must be positive');
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromInt(int $id): RecipientId
|
||||
{
|
||||
return new RecipientId($id);
|
||||
}
|
||||
|
||||
public function equals(RecipientId $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return (string) $this->value;
|
||||
}
|
||||
}
|
||||
77
src/Infrastructure/Api/RapidMail/RecipientListService.php
Normal file
77
src/Infrastructure/Api/RapidMail/RecipientListService.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Api\RapidMail;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
use App\Infrastructure\Api\RapidMail\ReadModels\RecipientList;
|
||||
|
||||
final readonly class RecipientListService
|
||||
{
|
||||
public function __construct(
|
||||
private RapidMailApiClient $apiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Empfängerliste
|
||||
*/
|
||||
public function create(string $name, ?string $description = null): RecipientList
|
||||
{
|
||||
$data = ['name' => $name];
|
||||
if ($description !== null) {
|
||||
$data['description'] = $description;
|
||||
}
|
||||
|
||||
$result = $this->apiClient->request(Method::POST, 'recipientlists', $data);
|
||||
return RecipientList::fromArray($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt alle Empfängerlisten
|
||||
* @return RecipientList[]
|
||||
*/
|
||||
public function getAll(int $page = 1, int $perPage = 50): array
|
||||
{
|
||||
$apiResponse = $this->apiClient->request(Method::GET, 'recipientlists', [], [
|
||||
'page' => $page,
|
||||
'per_page' => $perPage
|
||||
]);
|
||||
|
||||
$recipientlists = $apiResponse['_embedded']['recipientlists'] ?? [];
|
||||
|
||||
return array_map(
|
||||
fn(array $item) => RecipientList::fromArray($item),
|
||||
$recipientlists
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine spezifische Empfängerliste
|
||||
*/
|
||||
public function get(RecipientListId $recipientlistId): RecipientList
|
||||
{
|
||||
$data = $this->apiClient->request(Method::GET, "recipientlists/{$recipientlistId->value}");
|
||||
return RecipientList::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert eine Empfängerliste
|
||||
*/
|
||||
public function update(RecipientListId $recipientlistId, string $name, ?string $description = null): RecipientList
|
||||
{
|
||||
$data = ['name' => $name];
|
||||
if ($description !== null) {
|
||||
$data['description'] = $description;
|
||||
}
|
||||
|
||||
$result = $this->apiClient->request(Method::PUT, "recipientlists/{$recipientlistId->value}", $data);
|
||||
return RecipientList::fromArray($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht eine Empfängerliste
|
||||
*/
|
||||
public function delete(RecipientListId $recipientlistId): void
|
||||
{
|
||||
$this->apiClient->sendRawRequest(Method::DELETE, "recipientlists/{$recipientlistId->value}");
|
||||
}
|
||||
}
|
||||
114
src/Infrastructure/Api/RapidMail/RecipientService.php
Normal file
114
src/Infrastructure/Api/RapidMail/RecipientService.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Api\RapidMail;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
use App\Infrastructure\Api\RapidMail\Commands\CreateRecipientCommand;
|
||||
use App\Infrastructure\Api\RapidMail\Commands\UpdateRecipientCommand;
|
||||
use App\Infrastructure\Api\RapidMail\ReadModels\Recipient;
|
||||
|
||||
/**
|
||||
* Modern RecipientService with Value Objects and Command/Query separation
|
||||
*
|
||||
* Use the new methods (createWithCommand, get with RecipientId, etc.) for type safety.
|
||||
* Legacy methods are marked as deprecated but still functional for backward compatibility.
|
||||
*/
|
||||
final readonly class RecipientService
|
||||
{
|
||||
public function __construct(
|
||||
private RapidMailApiClient $apiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a new recipient using Command pattern (RECOMMENDED)
|
||||
*/
|
||||
public function createWithCommand(CreateRecipientCommand $command): Recipient
|
||||
{
|
||||
$data = $this->apiClient->request(
|
||||
Method::POST,
|
||||
'recipients',
|
||||
$command->toArray(),
|
||||
$this->apiClient->getDefaultQueryParams()
|
||||
);
|
||||
|
||||
return Recipient::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a recipient by ID using Value Object (RECOMMENDED)
|
||||
*/
|
||||
public function get(RecipientId $recipientId): Recipient
|
||||
{
|
||||
$data = $this->apiClient->request(Method::GET, "recipients/{$recipientId->value}");
|
||||
return Recipient::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a recipient using Command pattern (RECOMMENDED)
|
||||
*/
|
||||
public function updateWithCommand(UpdateRecipientCommand $command): Recipient
|
||||
{
|
||||
$data = $this->apiClient->request(
|
||||
Method::PATCH,
|
||||
"recipients/{$command->id->value}",
|
||||
$command->toArray()
|
||||
);
|
||||
|
||||
return Recipient::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a recipient using Value Object (RECOMMENDED)
|
||||
*/
|
||||
public function delete(RecipientId $recipientId): void
|
||||
{
|
||||
$this->apiClient->sendRawRequest(Method::DELETE, "recipients/{$recipientId->value}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for recipients with filters (RECOMMENDED)
|
||||
* @return Recipient[]
|
||||
*/
|
||||
public function search(array $filter = [], int $page = 1, int $perPage = 50): array
|
||||
{
|
||||
$queryParams = [
|
||||
'page' => $page,
|
||||
'per_page' => $perPage
|
||||
];
|
||||
|
||||
if (!empty($filter)) {
|
||||
$queryParams = array_merge($queryParams, $filter);
|
||||
}
|
||||
|
||||
$apiResponse = $this->apiClient->request(Method::GET, 'recipients', [], $queryParams);
|
||||
|
||||
$recipients = $apiResponse['_embedded']['recipients'] ?? [];
|
||||
|
||||
return array_map(
|
||||
fn(array $item) => Recipient::fromArray($item),
|
||||
$recipients
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a specific recipient by email (RECOMMENDED)
|
||||
*/
|
||||
public function findByEmail(string $email, ?RecipientListId $recipientListId = null): ?Recipient
|
||||
{
|
||||
$filter = ['email' => $email];
|
||||
if ($recipientListId !== null) {
|
||||
$filter['recipientlist_id'] = $recipientListId->value;
|
||||
}
|
||||
|
||||
$recipients = $this->search($filter, 1, 1);
|
||||
return empty($recipients) ? null : $recipients[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a recipient exists (RECOMMENDED)
|
||||
*/
|
||||
public function exists(string $email, ?RecipientListId $recipientListId = null): bool
|
||||
{
|
||||
return $this->findByEmail($email, $recipientListId) !== null;
|
||||
}
|
||||
}
|
||||
41
src/Infrastructure/Api/RapidMail/StatisticsService.php
Normal file
41
src/Infrastructure/Api/RapidMail/StatisticsService.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Api\RapidMail;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
|
||||
final readonly class StatisticsService
|
||||
{
|
||||
public function __construct(
|
||||
private RapidMailApiClient $apiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Holt Statistiken für ein Mailing
|
||||
*/
|
||||
public function getMailingStats(int $mailingId): array
|
||||
{
|
||||
return $this->apiClient->request(
|
||||
Method::GET,
|
||||
"mailings/{$mailingId}/stats",
|
||||
[],
|
||||
$this->apiClient->getDefaultQueryParams()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Klick-Statistiken für ein Mailing
|
||||
*/
|
||||
public function getClickStats(int $mailingId): array
|
||||
{
|
||||
return $this->apiClient->request(Method::GET, "mailings/{$mailingId}/stats/clicks");
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Öffnungs-Statistiken für ein Mailing
|
||||
*/
|
||||
public function getOpenStats(int $mailingId): array
|
||||
{
|
||||
return $this->apiClient->request(Method::GET, "mailings/{$mailingId}/stats/opens");
|
||||
}
|
||||
}
|
||||
96
src/Infrastructure/Api/RapidMailClient.php
Normal file
96
src/Infrastructure/Api/RapidMailClient.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api;
|
||||
|
||||
use App\Framework\Api\ApiException;
|
||||
use App\Framework\Api\ApiRequestTrait;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\HttpClient\AuthConfig;
|
||||
use App\Framework\HttpClient\ClientOptions;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
final class RapidMailClient
|
||||
{
|
||||
use ApiRequestTrait;
|
||||
|
||||
public function __construct(
|
||||
string $username,
|
||||
string $password,
|
||||
?HttpClient $httpClient = null
|
||||
) {
|
||||
$this->baseUrl = 'https://apiv3.emailsys.net/v1';
|
||||
$this->defaultOptions = new ClientOptions(
|
||||
auth: AuthConfig::basic($username, $password)
|
||||
);
|
||||
$this->httpClient = $httpClient ?? new CurlHttpClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt einen Empfänger zu einer Mailingliste hinzu
|
||||
*/
|
||||
public function addRecipient(string $name, string $email, int $recipientlistId): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::POST,
|
||||
endpoint: 'recipients',
|
||||
data: [
|
||||
'firstname' => $name,
|
||||
'email' => $email,
|
||||
'recipientlist_id' => $recipientlistId,
|
||||
],
|
||||
options: $this->defaultOptions->with([
|
||||
'query' => [
|
||||
'send_activationmail' => 'yes',
|
||||
'test_mode' => 'yes'
|
||||
]
|
||||
])
|
||||
);
|
||||
|
||||
return $this->decodeJson($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Empfänger aus einer Mailingliste
|
||||
*/
|
||||
public function deleteRecipient(int $recipientId): void
|
||||
{
|
||||
$this->sendRequest(
|
||||
method: Method::DELETE,
|
||||
endpoint: "recipients/{$recipientId}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft die Mailinglisten ab
|
||||
*/
|
||||
public function getRecipientLists(): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: 'recipientlists'
|
||||
);
|
||||
|
||||
return $this->decodeJson($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht nach Empfängern in einer Mailingliste
|
||||
*/
|
||||
public function searchRecipients(string $email, int $recipientlistId): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: 'recipients',
|
||||
data: [
|
||||
'filter' => [
|
||||
'email' => $email,
|
||||
'recipientlist_id' => $recipientlistId
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
return $this->decodeJson($response);
|
||||
}
|
||||
}
|
||||
378
src/Infrastructure/Api/ShopifyClient.php
Normal file
378
src/Infrastructure/Api/ShopifyClient.php
Normal file
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Api;
|
||||
|
||||
use App\Framework\Api\ApiException;
|
||||
use App\Framework\Api\ApiRequestTrait;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\HttpClient\AuthConfig;
|
||||
use App\Framework\HttpClient\ClientOptions;
|
||||
use App\Framework\HttpClient\ClientResponse;
|
||||
use App\Framework\HttpClient\CurlHttpClient;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
final class ShopifyClient
|
||||
{
|
||||
use ApiRequestTrait;
|
||||
|
||||
private string $apiVersion;
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Shopify API-Client
|
||||
*
|
||||
* @param string $shopDomain Die Shopify-Domain (z.B. 'my-store.myshopify.com')
|
||||
* @param string $accessToken Das Zugriffstoken für die API
|
||||
* @param string $apiVersion Die API-Version (z.B. '2023-10')
|
||||
* @param HttpClient|null $httpClient Ein optionaler HTTP-Client
|
||||
*/
|
||||
public function __construct(
|
||||
string $shopDomain,
|
||||
string $accessToken,
|
||||
string $apiVersion = '2024-04',
|
||||
?HttpClient $httpClient = null
|
||||
) {
|
||||
$this->baseUrl = "https://{$shopDomain}/admin/api/{$apiVersion}";
|
||||
$this->apiVersion = $apiVersion;
|
||||
|
||||
$this->defaultOptions = new ClientOptions(
|
||||
auth: AuthConfig::custom([
|
||||
'headers' => ['X-Shopify-Access-Token' => $accessToken]
|
||||
])
|
||||
);
|
||||
|
||||
$this->httpClient = $httpClient ?? new CurlHttpClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft eine Liste aller Produkte ab
|
||||
*
|
||||
* @param array $options Optionale Parameter (limit, since_id, usw.)
|
||||
* @return array Die Produktliste
|
||||
*/
|
||||
public function getProducts(array $options = []): array
|
||||
{
|
||||
$queryParams = $this->buildQueryParams($options);
|
||||
$endpoint = 'products.json' . $queryParams;
|
||||
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: $endpoint
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['products'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft ein einzelnes Produkt ab
|
||||
*
|
||||
* @param int $productId Die Produkt-ID
|
||||
* @return array Die Produktdaten
|
||||
*/
|
||||
public function getProduct(int $productId): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: "products/{$productId}.json"
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['product'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein neues Produkt
|
||||
*
|
||||
* @param array $productData Die Produktdaten
|
||||
* @return array Das erstellte Produkt
|
||||
*/
|
||||
public function createProduct(array $productData): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::POST,
|
||||
endpoint: 'products.json',
|
||||
data: ['product' => $productData]
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['product'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert ein bestehendes Produkt
|
||||
*
|
||||
* @param int $productId Die Produkt-ID
|
||||
* @param array $productData Die zu aktualisierenden Produktdaten
|
||||
* @return array Das aktualisierte Produkt
|
||||
*/
|
||||
public function updateProduct(int $productId, array $productData): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::PUT,
|
||||
endpoint: "products/{$productId}.json",
|
||||
data: ['product' => $productData]
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['product'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht ein Produkt
|
||||
*
|
||||
* @param int $productId Die Produkt-ID
|
||||
* @return bool Erfolg oder Misserfolg
|
||||
*/
|
||||
public function deleteProduct(int $productId): bool
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::DELETE,
|
||||
endpoint: "products/{$productId}.json"
|
||||
);
|
||||
|
||||
return $response->status->value === 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft eine Liste aller Bestellungen ab
|
||||
*
|
||||
* @param array $options Optionale Parameter (limit, status, usw.)
|
||||
* @return array Die Bestellungsliste
|
||||
*/
|
||||
public function getOrders(array $options = []): array
|
||||
{
|
||||
$queryParams = $this->buildQueryParams($options);
|
||||
$endpoint = 'orders.json' . $queryParams;
|
||||
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: $endpoint
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['orders'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft eine einzelne Bestellung ab
|
||||
*
|
||||
* @param int $orderId Die Bestellungs-ID
|
||||
* @return array Die Bestellungsdaten
|
||||
*/
|
||||
public function getOrder(int $orderId): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: "orders/{$orderId}.json"
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['order'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Bestellung
|
||||
*
|
||||
* @param array $orderData Die Bestellungsdaten
|
||||
* @return array Die erstellte Bestellung
|
||||
*/
|
||||
public function createOrder(array $orderData): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::POST,
|
||||
endpoint: 'orders.json',
|
||||
data: ['order' => $orderData]
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['order'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft Informationen über den Shop ab
|
||||
*
|
||||
* @return array Die Shop-Informationen
|
||||
*/
|
||||
public function getShopInfo(): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: 'shop.json'
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['shop'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft eine Liste aller Kunden ab
|
||||
*
|
||||
* @param array $options Optionale Parameter (limit, since_id, usw.)
|
||||
* @return array Die Kundenliste
|
||||
*/
|
||||
public function getCustomers(array $options = []): array
|
||||
{
|
||||
$queryParams = $this->buildQueryParams($options);
|
||||
$endpoint = 'customers.json' . $queryParams;
|
||||
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: $endpoint
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['customers'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Kunden
|
||||
*
|
||||
* @param array $customerData Die Kundendaten
|
||||
* @return array Der erstellte Kunde
|
||||
*/
|
||||
public function createCustomer(array $customerData): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::POST,
|
||||
endpoint: 'customers.json',
|
||||
data: ['customer' => $customerData]
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['customer'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft einen einzelnen Kunden ab
|
||||
*
|
||||
* @param int $customerId Die Kunden-ID
|
||||
* @return array Die Kundendaten
|
||||
*/
|
||||
public function getCustomer(int $customerId): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: "customers/{$customerId}.json"
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['customer'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Webhook
|
||||
*
|
||||
* @param string $topic Das Webhook-Thema (z.B. 'orders/create')
|
||||
* @param string $address Die URL, die aufgerufen werden soll
|
||||
* @param string $format Das Format (JSON oder XML)
|
||||
* @return array Die Webhook-Daten
|
||||
*/
|
||||
public function createWebhook(string $topic, string $address, string $format = 'json'): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::POST,
|
||||
endpoint: 'webhooks.json',
|
||||
data: [
|
||||
'webhook' => [
|
||||
'topic' => $topic,
|
||||
'address' => $address,
|
||||
'format' => $format
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['webhook'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft alle Webhooks ab
|
||||
*
|
||||
* @return array Die Liste der Webhooks
|
||||
*/
|
||||
public function getWebhooks(): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: 'webhooks.json'
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['webhooks'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht Produkte anhand von Suchkriterien
|
||||
*
|
||||
* @param string $query Die Suchanfrage
|
||||
* @param array $options Zusätzliche Optionen
|
||||
* @return array Die gefundenen Produkte
|
||||
*/
|
||||
public function searchProducts(string $query, array $options = []): array
|
||||
{
|
||||
$options = array_merge($options, ['query' => $query]);
|
||||
$queryParams = $this->buildQueryParams($options);
|
||||
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: 'products/search.json' . $queryParams
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['products'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft Metafields für eine Ressource ab
|
||||
*
|
||||
* @param string $resourceType Der Ressourcentyp (product, customer, usw.)
|
||||
* @param int $resourceId Die Ressourcen-ID
|
||||
* @return array Die Metafields
|
||||
*/
|
||||
public function getMetafields(string $resourceType, int $resourceId): array
|
||||
{
|
||||
$response = $this->sendRequest(
|
||||
method: Method::GET,
|
||||
endpoint: "{$resourceType}s/{$resourceId}/metafields.json"
|
||||
);
|
||||
|
||||
return $this->decodeJson($response)['metafields'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet Rate-Limiting-Informationen aus den Headern
|
||||
*
|
||||
* @param ClientResponse $response Die API-Antwort
|
||||
* @return array Rate-Limiting-Informationen
|
||||
*/
|
||||
public function getRateLimitInfo(ClientResponse $response): array
|
||||
{
|
||||
$headers = $response->headers->all();
|
||||
|
||||
return [
|
||||
'limit' => (int)($headers['X-Shopify-Shop-Api-Call-Limit'][0] ?? 0),
|
||||
'remaining' => isset($headers['X-Shopify-Shop-Api-Call-Limit'][0])
|
||||
? $this->parseRemainingCalls($headers['X-Shopify-Shop-Api-Call-Limit'][0])
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut Query-Parameter aus einem Options-Array
|
||||
*
|
||||
* @param array $options Die Optionen
|
||||
* @return string Die Query-Parameter als String
|
||||
*/
|
||||
private function buildQueryParams(array $options): string
|
||||
{
|
||||
if (empty($options)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '?' . http_build_query($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst die verbleibenden API-Aufrufe aus dem Header
|
||||
*
|
||||
* @param string $limitHeader Der Header-Wert
|
||||
* @return int|null Die verbleibenden Aufrufe
|
||||
*/
|
||||
private function parseRemainingCalls(string $limitHeader): ?int
|
||||
{
|
||||
if (preg_match('/^(\d+)\/(\d+)$/', $limitHeader, $matches)) {
|
||||
$current = (int)$matches[1];
|
||||
$limit = (int)$matches[2];
|
||||
return $limit - $current;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
27
src/Infrastructure/GeoIp/Country.php
Normal file
27
src/Infrastructure/GeoIp/Country.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\GeoIp;
|
||||
|
||||
final readonly class Country
|
||||
{
|
||||
public function __construct(
|
||||
public string $code,
|
||||
public string $nameEn,
|
||||
public string $nameDe,
|
||||
public string $nameNative,
|
||||
public string $updatedAt
|
||||
) {
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'code' => $this->code,
|
||||
'name_en' => $this->nameEn,
|
||||
'name_de' => $this->nameDe,
|
||||
'name_native' => $this->nameNative,
|
||||
'updated_at' => $this->updatedAt
|
||||
];
|
||||
}
|
||||
}
|
||||
100
src/Infrastructure/GeoIp/CountryDataService.php
Normal file
100
src/Infrastructure/GeoIp/CountryDataService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\GeoIp;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class CountryDataService
|
||||
{
|
||||
private const string RESTCOUNTRIES_API_URL = 'https://restcountries.com/v3.1/all?fields=cca2,name,translations';
|
||||
|
||||
public function __construct(
|
||||
private readonly PDO $database
|
||||
) {
|
||||
}
|
||||
|
||||
public function loadCountryData(): int
|
||||
{
|
||||
echo "Lade Länderdaten von RestCountries API...\n";
|
||||
|
||||
$jsonData = file_get_contents(self::RESTCOUNTRIES_API_URL);
|
||||
if (!$jsonData) {
|
||||
throw new RuntimeException('Konnte Länderdaten nicht herunterladen');
|
||||
}
|
||||
|
||||
$countries = json_decode($jsonData, true);
|
||||
if (!$countries) {
|
||||
throw new RuntimeException('Ungültige JSON-Daten erhalten');
|
||||
}
|
||||
|
||||
$this->database->beginTransaction();
|
||||
$this->database->exec('DELETE FROM countries');
|
||||
|
||||
$stmt = $this->database->prepare('INSERT INTO countries (code, name_en, name_de, name_native, updated_at) VALUES (?, ?, ?, ?, ?)');
|
||||
|
||||
$processed = 0;
|
||||
foreach ($countries as $countryData) {
|
||||
$country = $this->parseCountryData($countryData);
|
||||
if ($country) {
|
||||
$stmt->execute([
|
||||
$country->code,
|
||||
$country->nameEn,
|
||||
$country->nameDe,
|
||||
$country->nameNative,
|
||||
$country->updatedAt
|
||||
]);
|
||||
$processed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->database->commit();
|
||||
echo "Länderdatenbank aktualisiert: {$processed} Länder geladen\n";
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
public function getCountryByCode(string $code): ?Country
|
||||
{
|
||||
$stmt = $this->database->prepare('SELECT * FROM countries WHERE code = ?');
|
||||
$stmt->execute([$code]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Country(
|
||||
code: $result['code'],
|
||||
nameEn: $result['name_en'],
|
||||
nameDe: $result['name_de'],
|
||||
nameNative: $result['name_native'],
|
||||
updatedAt: $result['updated_at']
|
||||
);
|
||||
}
|
||||
|
||||
private function parseCountryData(array $countryData): ?Country
|
||||
{
|
||||
$code = $countryData['cca2'] ?? null;
|
||||
$nameEn = $countryData['name']['common'] ?? null;
|
||||
|
||||
if (!$code || !$nameEn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nameDe = $countryData['translations']['deu']['common'] ?? $nameEn;
|
||||
$nativeNames = $countryData['name']['nativeName'] ?? [];
|
||||
$nameNative = !empty($nativeNames)
|
||||
? $nativeNames[array_key_first($nativeNames)]['common'] ?? $nameEn
|
||||
: $nameEn;
|
||||
|
||||
return new Country(
|
||||
code: $code,
|
||||
nameEn: $nameEn,
|
||||
nameDe: $nameDe,
|
||||
nameNative: $nameNative,
|
||||
updatedAt: date('Y-m-d H:i:s')
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/Infrastructure/GeoIp/CountryInfo.php
Normal file
34
src/Infrastructure/GeoIp/CountryInfo.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\GeoIp;
|
||||
|
||||
final readonly class CountryInfo
|
||||
{
|
||||
public function __construct(
|
||||
public string $ip,
|
||||
public ?string $countryCode,
|
||||
public ?Country $country = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function hasCountry(): bool
|
||||
{
|
||||
return $this->countryCode !== null;
|
||||
}
|
||||
|
||||
public function getGermanName(): ?string
|
||||
{
|
||||
return $this->country?->nameDe ?? $this->countryCode;
|
||||
}
|
||||
|
||||
public function getEnglishName(): ?string
|
||||
{
|
||||
return $this->country?->nameEn ?? $this->countryCode;
|
||||
}
|
||||
|
||||
public function getNativeName(): ?string
|
||||
{
|
||||
return $this->country?->nameNative ?? $this->countryCode;
|
||||
}
|
||||
}
|
||||
80
src/Infrastructure/GeoIp/DatabaseSetup.php
Normal file
80
src/Infrastructure/GeoIp/DatabaseSetup.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\GeoIp;
|
||||
|
||||
use PDO;
|
||||
|
||||
final readonly class DatabaseSetup
|
||||
{
|
||||
private PDO $database;
|
||||
|
||||
public function __construct(string $databasePath)
|
||||
{
|
||||
$this->database = $this->initializeDatabase($databasePath);
|
||||
}
|
||||
|
||||
public function setupCompleteDatabase(): void
|
||||
{
|
||||
echo "=== GeoIP-Datenbank Setup ===\n";
|
||||
|
||||
// 1. Länderdaten laden
|
||||
echo "Schritt 1: Lade Länderdaten...\n";
|
||||
$countryService = new CountryDataService($this->database);
|
||||
$countryService->loadCountryData();
|
||||
|
||||
// 2. IP-Ranges laden
|
||||
echo "Schritt 2: Lade IP-Ranges...\n";
|
||||
$ipRangeService = new IpRangeService($this->database);
|
||||
$ipRangeService->loadIpRanges();
|
||||
|
||||
echo "Setup abgeschlossen!\n";
|
||||
}
|
||||
|
||||
public function setupCountryDataOnly(): void
|
||||
{
|
||||
echo "=== Länderdaten Setup ===\n";
|
||||
$countryService = new CountryDataService($this->database);
|
||||
$countryService->loadCountryData();
|
||||
echo "Länderdaten Setup abgeschlossen!\n";
|
||||
}
|
||||
|
||||
public function setupIpRangesOnly(): void
|
||||
{
|
||||
echo "=== IP-Ranges Setup ===\n";
|
||||
$ipRangeService = new IpRangeService($this->database);
|
||||
$ipRangeService->loadIpRanges();
|
||||
echo "IP-Ranges Setup abgeschlossen!\n";
|
||||
}
|
||||
|
||||
private function initializeDatabase(string $file): PDO
|
||||
{
|
||||
$directory = dirname($file);
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
$db = new PDO('sqlite:' . $file);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
// IP-Ranges Tabelle
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS ip_ranges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ip_start INTEGER,
|
||||
ip_end INTEGER,
|
||||
country TEXT
|
||||
)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_ip_range ON ip_ranges (ip_start, ip_end)');
|
||||
|
||||
// Länder-Tabelle für ISO-Daten
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS countries (
|
||||
code TEXT PRIMARY KEY,
|
||||
name_en TEXT,
|
||||
name_de TEXT,
|
||||
name_native TEXT,
|
||||
updated_at TEXT
|
||||
)');
|
||||
|
||||
return $db;
|
||||
}
|
||||
}
|
||||
113
src/Infrastructure/GeoIp/GeoIp.php
Normal file
113
src/Infrastructure/GeoIp/GeoIp.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\GeoIp;
|
||||
|
||||
use App\Framework\Http\IpAddress;
|
||||
use PDO;
|
||||
|
||||
final class GeoIp
|
||||
{
|
||||
private readonly PDO $database;
|
||||
private readonly IpRangeService $ipRangeService;
|
||||
private readonly CountryDataService $countryDataService;
|
||||
|
||||
public function __construct(?string $databasePath = null)
|
||||
{
|
||||
$databasePath = $databasePath ?? __DIR__ . '/data/ip_country.sqlite';
|
||||
$this->database = $this->initializeDatabase($databasePath);
|
||||
$this->ipRangeService = new IpRangeService($this->database);
|
||||
$this->countryDataService = new CountryDataService($this->database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use DatabaseSetup::setupCompleteDatabase() instead
|
||||
*/
|
||||
public function loadDatabase(): void
|
||||
{
|
||||
$setup = new DatabaseSetup(__DIR__ . '/data/ip_country.sqlite');
|
||||
$setup->setupIpRangesOnly();
|
||||
}
|
||||
|
||||
public function getCountryForString(string $ip): ?string
|
||||
{
|
||||
return $this->ipRangeService->getCountryCodeForIp($ip);
|
||||
}
|
||||
|
||||
public function getCountryForIp(IpAddress $ipAddress): ?string
|
||||
{
|
||||
if ($ipAddress->isPrivate()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->getCountryForString($ipAddress->value);
|
||||
}
|
||||
|
||||
public function getCountryInfo(string $ip): CountryInfo
|
||||
{
|
||||
$countryCode = $this->getCountryForString($ip);
|
||||
|
||||
if (!$countryCode) {
|
||||
return new CountryInfo($ip, null);
|
||||
}
|
||||
|
||||
$country = $this->countryDataService->getCountryByCode($countryCode);
|
||||
|
||||
return new CountryInfo($ip, $countryCode, $country);
|
||||
}
|
||||
|
||||
public function getCountryInfoForIp(IpAddress $ipAddress): CountryInfo
|
||||
{
|
||||
if ($ipAddress->isPrivate()) {
|
||||
return new CountryInfo($ipAddress->value, null);
|
||||
}
|
||||
|
||||
return $this->getCountryInfo($ipAddress->value);
|
||||
}
|
||||
|
||||
public function getCountryNameGerman(string $ip): ?string
|
||||
{
|
||||
return $this->getCountryInfo($ip)->getGermanName();
|
||||
}
|
||||
|
||||
public function getCountryNameEnglish(string $ip): ?string
|
||||
{
|
||||
return $this->getCountryInfo($ip)->getEnglishName();
|
||||
}
|
||||
|
||||
public function getCountryNameNative(string $ip): ?string
|
||||
{
|
||||
return $this->getCountryInfo($ip)->getNativeName();
|
||||
}
|
||||
|
||||
private function initializeDatabase(string $file): PDO
|
||||
{
|
||||
$directory = dirname($file);
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
$db = new PDO('sqlite:' . $file);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
// IP-Ranges Tabelle
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS ip_ranges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ip_start INTEGER,
|
||||
ip_end INTEGER,
|
||||
country TEXT
|
||||
)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_ip_range ON ip_ranges (ip_start, ip_end)');
|
||||
|
||||
// Länder-Tabelle für ISO-Daten
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS countries (
|
||||
code TEXT PRIMARY KEY,
|
||||
name_en TEXT,
|
||||
name_de TEXT,
|
||||
name_native TEXT,
|
||||
updated_at TEXT
|
||||
)');
|
||||
|
||||
return $db;
|
||||
}
|
||||
}
|
||||
129
src/Infrastructure/GeoIp/IpRangeService.php
Normal file
129
src/Infrastructure/GeoIp/IpRangeService.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\GeoIp;
|
||||
|
||||
use PDO;
|
||||
use Generator;
|
||||
use RuntimeException;
|
||||
|
||||
final class IpRangeService
|
||||
{
|
||||
private const array RIR_SOURCES = [
|
||||
'RIPE' => 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-latest', // Europa, Westasien, Teile Afrikas
|
||||
'ARIN' => 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest', // Nordamerika
|
||||
'APNIC' => 'https://ftp.apnic.net/pub/stats/apnic/delegated-apnic-latest', // Asien-Pazifik
|
||||
'LACNIC' => 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-latest', // Lateinamerika
|
||||
'AFRINIC' => 'https://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-latest' // Afrika
|
||||
];
|
||||
|
||||
private const int BATCH_SIZE = 1000;
|
||||
|
||||
public function __construct(
|
||||
private readonly PDO $database
|
||||
) {
|
||||
}
|
||||
|
||||
public function loadIpRanges(): int
|
||||
{
|
||||
$this->database->exec('DELETE FROM ip_ranges');
|
||||
|
||||
$this->database->beginTransaction();
|
||||
$stmt = $this->database->prepare('INSERT INTO ip_ranges (ip_start, ip_end, country) VALUES (?, ?, ?)');
|
||||
|
||||
$totalProcessed = 0;
|
||||
|
||||
foreach (self::RIR_SOURCES as $rir => $url) {
|
||||
echo "Verarbeite {$rir}-Datenbank...\n";
|
||||
|
||||
try {
|
||||
$processed = 0;
|
||||
foreach ($this->parseIpRanges($url) as $ipRange) {
|
||||
$stmt->execute([$ipRange['ip_start'], $ipRange['ip_end'], $ipRange['country']]);
|
||||
$processed++;
|
||||
$totalProcessed++;
|
||||
|
||||
if ($totalProcessed % self::BATCH_SIZE === 0) {
|
||||
$this->database->commit();
|
||||
$this->database->beginTransaction();
|
||||
echo "Verarbeitet: {$totalProcessed} Einträge insgesamt\n";
|
||||
}
|
||||
}
|
||||
echo "{$rir}: {$processed} Einträge hinzugefügt\n";
|
||||
|
||||
} catch (\Exception $e) {
|
||||
echo "Fehler bei {$rir}: " . $e->getMessage() . "\n";
|
||||
// Weiter mit nächster Datenbank
|
||||
}
|
||||
}
|
||||
|
||||
$this->database->commit();
|
||||
echo "Datenbank-Aufbau abgeschlossen. Insgesamt {$totalProcessed} Einträge aus allen RIRs.\n";
|
||||
|
||||
return $totalProcessed;
|
||||
}
|
||||
|
||||
public function getCountryCodeForIp(string $ip): ?string
|
||||
{
|
||||
$ipLong = ip2long($ip);
|
||||
if ($ipLong === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->database->prepare('SELECT country FROM ip_ranges WHERE ip_start <= ? AND ip_end >= ?');
|
||||
$stmt->execute([$ipLong, $ipLong]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $result ? $result['country'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator für speicherschonende Verarbeitung der IP-Ranges
|
||||
*/
|
||||
private function parseIpRanges(string $url): Generator
|
||||
{
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => 300 // 5 Minuten Timeout
|
||||
]
|
||||
]);
|
||||
|
||||
$handle = fopen($url, 'r', false, $context);
|
||||
if (!$handle) {
|
||||
throw new RuntimeException("Konnte URL nicht öffnen: {$url}");
|
||||
}
|
||||
|
||||
try {
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$line = trim($line);
|
||||
|
||||
if (str_contains($line, '|ipv4|')) {
|
||||
$parts = explode('|', $line);
|
||||
|
||||
if (count($parts) < 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$country = $parts[1];
|
||||
$ip = $parts[3];
|
||||
$count = (int)$parts[4];
|
||||
|
||||
$ipStart = ip2long($ip);
|
||||
if ($ipStart === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ipEnd = $ipStart + $count - 1;
|
||||
|
||||
yield [
|
||||
'ip_start' => $ipStart,
|
||||
'ip_end' => $ipEnd,
|
||||
'country' => $country
|
||||
];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user