chore: complete update

This commit is contained in:
2025-07-17 16:24:20 +02:00
parent 899227b0a4
commit 64a7051137
1300 changed files with 85570 additions and 2756 deletions

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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