feat: add API Gateway, RapidMail and Shopify integrations, update WireGuard configs, add Redis override and architecture docs

This commit is contained in:
2025-11-04 23:08:17 +01:00
parent 5d6edea3bb
commit f9b8cf9f33
23 changed files with 3621 additions and 8 deletions

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\HasPayload;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url;
use App\Framework\Retry\ExponentialBackoffStrategy;
use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
/**
* Domain-specific API request for creating recipients in RapidMail
*
* Example usage:
*
* $request = new CreateRecipientApiRequest(
* config: $rapidMailConfig,
* recipientData: [
* 'email' => 'user@example.com',
* 'firstname' => 'John',
* 'lastname' => 'Doe'
* ]
* );
*
* $response = $apiGateway->send($request);
*/
final readonly class CreateRecipientApiRequest implements ApiRequest, HasPayload
{
public function __construct(
private RapidMailConfig $config,
private array $recipientData
) {
}
public function getEndpoint(): ApiEndpoint
{
$url = rtrim($this->config->baseUrl, '/') . '/recipients';
return ApiEndpoint::fromUrl(Url::parse($url));
}
public function getMethod(): HttpMethod
{
return HttpMethod::POST;
}
public function getPayload(): array
{
return $this->recipientData;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds((int) $this->config->timeout);
}
public function getRetryStrategy(): ?RetryStrategy
{
// Exponential backoff: 1s, 2s, 4s
return new ExponentialBackoffStrategy(
maxAttempts: 3,
baseDelaySeconds: 1,
maxDelaySeconds: 10
);
}
public function getHeaders(): Headers
{
// Basic Auth
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
return new Headers([
'Authorization' => "Basic {$credentials}",
'Content-Type' => 'application/json',
'Accept' => 'application/json',
]);
}
public function getRequestName(): string
{
return 'rapidmail.create_recipient';
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url;
use App\Framework\Retry\ExponentialBackoffStrategy;
use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
/**
* Domain-specific API request for deleting a recipient from RapidMail
*
* Example usage:
*
* $request = new DeleteRecipientApiRequest(
* config: $rapidMailConfig,
* recipientId: '12345'
* );
*
* $response = $apiGateway->send($request);
*/
final readonly class DeleteRecipientApiRequest implements ApiRequest
{
public function __construct(
private RapidMailConfig $config,
private string $recipientId
) {
}
public function getEndpoint(): ApiEndpoint
{
$url = rtrim($this->config->baseUrl, '/') . '/recipients/' . $this->recipientId;
return ApiEndpoint::fromUrl(Url::parse($url));
}
public function getMethod(): HttpMethod
{
return HttpMethod::DELETE;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds((int) $this->config->timeout);
}
public function getRetryStrategy(): ?RetryStrategy
{
// Exponential backoff: 1s, 2s, 4s
return new ExponentialBackoffStrategy(
maxAttempts: 3,
baseDelaySeconds: 1,
maxDelaySeconds: 10
);
}
public function getHeaders(): Headers
{
// Basic Auth
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
return new Headers([
'Authorization' => "Basic {$credentials}",
'Accept' => 'application/json',
]);
}
public function getRequestName(): string
{
return 'rapidmail.delete_recipient';
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url;
use App\Framework\Retry\ExponentialBackoffStrategy;
use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
/**
* Domain-specific API request for retrieving a recipient from RapidMail
*
* Example usage:
*
* $request = new GetRecipientApiRequest(
* config: $rapidMailConfig,
* recipientId: '12345'
* );
*
* $response = $apiGateway->send($request);
*/
final readonly class GetRecipientApiRequest implements ApiRequest
{
public function __construct(
private RapidMailConfig $config,
private string $recipientId
) {
}
public function getEndpoint(): ApiEndpoint
{
$url = rtrim($this->config->baseUrl, '/') . '/recipients/' . $this->recipientId;
return ApiEndpoint::fromUrl(Url::parse($url));
}
public function getMethod(): HttpMethod
{
return HttpMethod::GET;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds((int) $this->config->timeout);
}
public function getRetryStrategy(): ?RetryStrategy
{
// Exponential backoff: 1s, 2s, 4s
return new ExponentialBackoffStrategy(
maxAttempts: 3,
baseDelaySeconds: 1,
maxDelaySeconds: 10
);
}
public function getHeaders(): Headers
{
// Basic Auth
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
return new Headers([
'Authorization' => "Basic {$credentials}",
'Accept' => 'application/json',
]);
}
public function getRequestName(): string
{
return 'rapidmail.get_recipient';
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url;
use App\Framework\Retry\ExponentialBackoffStrategy;
use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
/**
* Domain-specific API request for searching recipients in RapidMail
*
* Example usage:
*
* $request = new SearchRecipientsApiRequest(
* config: $rapidMailConfig,
* filter: ['email' => 'user@example.com'],
* page: 1,
* perPage: 50
* );
*
* $response = $apiGateway->send($request);
*/
final readonly class SearchRecipientsApiRequest implements ApiRequest
{
public function __construct(
private RapidMailConfig $config,
private array $filter = [],
private int $page = 1,
private int $perPage = 50
) {
}
public function getEndpoint(): ApiEndpoint
{
$baseUrl = rtrim($this->config->baseUrl, '/') . '/recipients';
// Build query parameters
$queryParams = [
'page' => (string) $this->page,
'per_page' => (string) $this->perPage,
];
// Merge filter parameters
if (!empty($this->filter)) {
foreach ($this->filter as $key => $value) {
$queryParams[$key] = is_array($value) ? json_encode($value) : (string) $value;
}
}
// Build query string
$queryString = http_build_query($queryParams);
// Create URL with query parameters
$url = Url::parse($baseUrl)->withQuery($queryString);
return ApiEndpoint::fromUrl($url);
}
public function getMethod(): HttpMethod
{
return HttpMethod::GET;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds((int) $this->config->timeout);
}
public function getRetryStrategy(): ?RetryStrategy
{
// Exponential backoff: 1s, 2s, 4s
return new ExponentialBackoffStrategy(
maxAttempts: 3,
baseDelaySeconds: 1,
maxDelaySeconds: 10
);
}
public function getHeaders(): Headers
{
// Basic Auth
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
return new Headers([
'Authorization' => "Basic {$credentials}",
'Accept' => 'application/json',
]);
}
public function getRequestName(): string
{
return 'rapidmail.search_recipients';
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\HasPayload;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url;
use App\Framework\Retry\ExponentialBackoffStrategy;
use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
/**
* Domain-specific API request for updating a recipient in RapidMail
*
* Example usage:
*
* $request = new UpdateRecipientApiRequest(
* config: $rapidMailConfig,
* recipientId: '12345',
* recipientData: [
* 'firstname' => 'Jane',
* 'lastname' => 'Doe'
* ]
* );
*
* $response = $apiGateway->send($request);
*/
final readonly class UpdateRecipientApiRequest implements ApiRequest, HasPayload
{
public function __construct(
private RapidMailConfig $config,
private string $recipientId,
private array $recipientData
) {
}
public function getEndpoint(): ApiEndpoint
{
$url = rtrim($this->config->baseUrl, '/') . '/recipients/' . $this->recipientId;
return ApiEndpoint::fromUrl(Url::parse($url));
}
public function getMethod(): HttpMethod
{
return HttpMethod::PATCH;
}
public function getPayload(): array
{
return $this->recipientData;
}
public function getTimeout(): Duration
{
return Duration::fromSeconds((int) $this->config->timeout);
}
public function getRetryStrategy(): ?RetryStrategy
{
// Exponential backoff: 1s, 2s, 4s
return new ExponentialBackoffStrategy(
maxAttempts: 3,
baseDelaySeconds: 1,
maxDelaySeconds: 10
);
}
public function getHeaders(): Headers
{
// Basic Auth
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
return new Headers([
'Authorization' => "Basic {$credentials}",
'Content-Type' => 'application/json',
'Accept' => 'application/json',
]);
}
public function getRequestName(): string
{
return 'rapidmail.update_recipient';
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail;
use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url;
use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\RapidMail\ValueObjects\{EmailAddress, EmailTemplate, RapidMailApiKey};
/**
* Domain-specific API request for sending emails via RapidMail
*
* Example usage:
*
* $request = new SendEmailApiRequest(
* apiKey: $apiKey,
* to: EmailAddress::fromString('user@example.com'),
* template: EmailTemplate::fromString('welcome'),
* data: ['name' => 'John Doe']
* );
*
* $response = $apiGateway->send($request);
*/
final readonly class SendEmailApiRequest implements ApiRequest
{
public function __construct(
private RapidMailApiKey $apiKey,
private EmailAddress $to,
private EmailTemplate $template,
private array $data = [],
private ?RetryStrategy $retryStrategy = null
) {
}
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(
Url::parse('https://api.rapidmail.com/v1/send')
);
}
public function getMethod(): HttpMethod
{
return HttpMethod::POST;
}
public function getPayload(): array
{
return [
'to' => $this->to->value,
'template' => $this->template->value,
'data' => $this->data,
];
}
public function getTimeout(): Duration
{
// Email sending can take a bit longer
return Duration::fromSeconds(15);
}
public function getRetryStrategy(): ?RetryStrategy
{
return $this->retryStrategy;
}
public function getHeaders(): Headers
{
return new Headers([
'Authorization' => "Bearer {$this->apiKey->value}",
'Content-Type' => 'application/json',
'Accept' => 'application/json',
]);
}
public function getRequestName(): string
{
return 'rapidmail.send_email';
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ValueObjects;
use InvalidArgumentException;
/**
* Value object for email address
*/
final readonly class EmailAddress
{
public function __construct(
public string $value
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email address: {$value}");
}
}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ValueObjects;
use InvalidArgumentException;
/**
* Value object for email template identifier
*/
final readonly class EmailTemplate
{
public function __construct(
public string $value
) {
if (empty($value)) {
throw new InvalidArgumentException('Email template identifier cannot be empty');
}
if (!preg_match('/^[a-z0-9_-]+$/', $value)) {
throw new InvalidArgumentException(
'Email template identifier must contain only lowercase letters, numbers, hyphens and underscores'
);
}
}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\RapidMail\ValueObjects;
use InvalidArgumentException;
/**
* Value object for RapidMail API key
*/
final readonly class RapidMailApiKey
{
public function __construct(
public string $value
) {
if (empty($value)) {
throw new InvalidArgumentException('RapidMail API key cannot be empty');
}
if (strlen($value) < 32) {
throw new InvalidArgumentException('RapidMail API key appears invalid (too short)');
}
}
public static function fromString(string $value): self
{
return new self($value);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Shopify;
use App\Framework\ApiGateway\ApiRequest;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url;
use App\Framework\Retry\RetryStrategy;
use App\Infrastructure\Api\Shopify\ValueObjects\{ShopifyApiKey, ShopifyStore};
/**
* Domain-specific API request for creating orders in Shopify
*
* Example usage:
*
* $request = new CreateOrderApiRequest(
* apiKey: $apiKey,
* store: ShopifyStore::fromString('mystore'),
* orderData: [
* 'line_items' => [
* ['variant_id' => 123, 'quantity' => 2]
* ],
* 'customer' => ['email' => 'customer@example.com']
* ]
* );
*
* $response = $apiGateway->send($request);
*/
final readonly class CreateOrderApiRequest implements ApiRequest
{
public function __construct(
private ShopifyApiKey $apiKey,
private ShopifyStore $store,
private array $orderData,
private ?RetryStrategy $retryStrategy = null
) {
}
public function getEndpoint(): ApiEndpoint
{
$shopUrl = "https://{$this->store->value}.myshopify.com/admin/api/2024-01/orders.json";
return ApiEndpoint::fromUrl(
Url::parse($shopUrl)
);
}
public function getMethod(): HttpMethod
{
return HttpMethod::POST;
}
public function getPayload(): array
{
return [
'order' => $this->orderData,
];
}
public function getTimeout(): Duration
{
// Order creation should be quick
return Duration::fromSeconds(10);
}
public function getRetryStrategy(): ?RetryStrategy
{
return $this->retryStrategy;
}
public function getHeaders(): Headers
{
return new Headers([
'X-Shopify-Access-Token' => $this->apiKey->value,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
]);
}
public function getRequestName(): string
{
return 'shopify.create_order';
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Shopify\ValueObjects;
use InvalidArgumentException;
/**
* Value object for Shopify API access token
*/
final readonly class ShopifyApiKey
{
public function __construct(
public string $value
) {
if (empty($value)) {
throw new InvalidArgumentException('Shopify API key cannot be empty');
}
// Shopify access tokens are typically 32+ characters
if (strlen($value) < 32) {
throw new InvalidArgumentException('Shopify API key appears invalid (too short)');
}
}
public static function fromString(string $value): self
{
return new self($value);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Shopify\ValueObjects;
use InvalidArgumentException;
/**
* Value object for Shopify store identifier
*/
final readonly class ShopifyStore
{
public function __construct(
public string $value
) {
if (empty($value)) {
throw new InvalidArgumentException('Shopify store identifier cannot be empty');
}
// Shopify store names are lowercase alphanumeric with hyphens
if (!preg_match('/^[a-z0-9][a-z0-9-]*[a-z0-9]$/', $value)) {
throw new InvalidArgumentException(
'Shopify store identifier must be lowercase alphanumeric with hyphens'
);
}
}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function __toString(): string
{
return $this->toString();
}
/**
* Get the full Shopify URL for this store
*/
public function getShopifyUrl(): string
{
return "https://{$this->value}.myshopify.com";
}
}